Implementasi Clean Architecture di Flutter dengan GetIt dan Dartz: Separation of Concerns Praktis
Memisahkan Tanggung Jawab Aplikasi ke dalam Tiga Lapisan Utama
Ketika seluruh logic ditulis langsung di dalam widget, satu file bisa berisi request jaringan, parsing JSON, validasi, dan tampilan sekaligus. Kondisi ini membuat kode sulit diuji karena perubahan kecil pada format API memaksa kita membuka ulang lapisan UI. Regression juga lebih sering muncul saat tidak ada batas jelas antara aturan bisnis dan detail teknis.
Clean Architecture menata kode ke dalam tiga lapisan: presentation, domain, dan data. Aturan dependensi berjalan satu arah. Lapisan luar boleh bergantung pada lapisan dalam, tetapi domain tidak boleh bergantung pada flutter, http, atau database. Domain menjadi pusat yang stabil, sementara detail teknis berada di tepi.

Gambar: Diagram Clean Architecture dengan aturan dependensi yang mengarah ke lapisan dalam (domain) — Sumber: Wikimedia Commons
Tanggung jawab tiap lapisan tegas. Presentation menangani UI dan state, domain menyimpan business rule lewat entity dan use case, dan data menangani sumber data eksternal. Struktur folder kita susun mengikuti pemisahan ini, misalnya presentation/, domain/, dan data/ pada level fitur. Perubahan pada API atau database tidak menyentuh UI selama kontrak domain tetap sama. Kita juga bisa mengganti implementasi repository untuk keperluan testing tanpa membuka use case. Struktur seperti ini mempercepat onboarding karena lokasi setiap tanggung jawab mudah diprediksi.
Membangun Lapisan Domain yang Tidak Bergantung pada Framework
Lapisan domain berdiri di atas Dart murni. Di dalamnya ada entity sebagai objek bisnis dan use case yang mewakili satu aksi. Repository di lapisan ini hanya berupa kontrak abstrak, sementara implementasi nyata baru muncul di lapisan data.
Entity seperti User tidak menyentuh flutter, http, atau library eksternal apa pun. Kita mendefinisikan field dan konstruktor saja. Kontrak UserRepository hanya mendeklarasikan method, tanpa tahu dari mana data berasal. Package dartz yang kita pakai adalah pure Dart package, bukan framework UI.
Use case menerima dependency lewat constructor, bukan membuat objeknya sendiri. Pola ini menjaga use case tetap dapat diuji dengan mock. Berikut gambaran file di lapisan domain:
import 'package:dartz/dartz.dart';
class Failure {
final String message;
Failure(this.message);
}
class User {
final String id;
final String name;
final String email;
const User({required this.id, required this.name, required this.email});
}
abstract class UserRepository {
Future<Either<Failure, User>> getUserProfile(String id);
}
class GetUserProfileUseCase {
final UserRepository repository;
GetUserProfileUseCase(this.repository);
Future<Either<Failure, User>> call(String id) {
return repository.getUserProfile(id);
}
}GetUserProfileUseCase menjembatani pemanggil dengan kontrak repository tanpa tahu detail jaringan. Kita menyuntikkan UserRepository lewat constructor agar use case tidak peduli implementasi mana yang aktif. Expected output ketika dipanggil nanti adalah Right(User) saat sukses atau Left(Failure) saat gagal, namun lapisan domain belum menghasilkan runtime sendiri karena kontrak belum terhubung ke data nyata.
Menghubungkan Lapisan Data ke Kontrak Domain melalui Repository
Lapisan data menyediakan implementasi konkret dari kontrak UserRepository. Di sinilah kita berurusan dengan jaringan dan penyimpanan lokal. Kita pisahkan data source menjadi remote untuk API dan local untuk cache, sehingga sumber data mudah diganti atau ditambah tanpa mengubah use case.
Model menjadi representasi data mentah yang tahu cara serialisasi dan deserialisasi JSON. Mapping dari model ke entity dilakukan di repository, sehingga entity tetap bersih dan hanya model yang bergantung pada format API. Model tetap berada di data layer, sedangkan entity naik ke domain.
Advanced Flutter State Management with BLoC
Master advanced Flutter state management by building a production-ready applicat...
Pemisahan ini terlihat manfaatnya saat backend berubah. Kita cukup mengubah data layer; use case dan UI tetap utuh. Berikut implementasi UserRepositoryImpl yang mengambil data dari remote data source, memetakannya ke entity, dan membungkus error menjadi Failure:
import 'package:dartz/dartz.dart';
class UserModel {
final String id;
final String name;
final String email;
UserModel({required this.id, required this.name, required this.email});
factory UserModel.fromJson(Map<String, dynamic> json) => UserModel(
id: json['id'] as String,
name: json['name'] as String,
email: json['email'] as String,
);
User toEntity() => User(id: id, name: name, email: email);
}
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource remoteDataSource;
UserRepositoryImpl(this.remoteDataSource);
@override
Future<Either<Failure, User>> getUserProfile(String id) async {
try {
final model = await remoteDataSource.fetchUser(id);
return Right(model.toEntity());
} on Exception {
return Left(Failure('Gagal memuat profil dari server'));
}
}
}Alur di atas dimulai dari request ke UserRemoteDataSource. Data mentah dikembalikan sebagai UserModel, lalu toEntity() mengubahnya menjadi User yang dikenal domain. Exception dari jaringan ditangkap dan diubah menjadi Left(Failure), sehingga UI tidak pernah menerima exception mentah.
Mengelola Dependency Injection Menggunakan GetIt
GetIt bekerja sebagai service locator: menyimpan instance dan menyuntikkannya saat dibutuhkan. Pola ini menghindari pembuatan objek manual berantai di dalam widget. Kita mendaftarkan dependency sekali di awal aplikasi, lalu memanggilnya dari mana saja melalui GetIt.I.

Gambar: Diagram kelas UML pola Service Locator yang mendasari cara kerja GetIt — Sumber: Wikimedia Commons
Terdapat tiga method registrasi utama. registerSingleton membuat instance saat didaftarkan, cocok untuk objek ringan. registerLazySingleton menunda pembuatan instance sampai pertama kali dipanggil, cocok untuk dependency mahal yang dipakai berulang. registerFactory membuat instance baru setiap kali diminta, cocok untuk objek yang harus bersih tiap penggunaan.
Urutan registrasi mengikuti arah dependensi: data source lebih dulu, lalu repository, kemudian use case. Berikut file injection.dart yang merangkai seluruh komponen:
import 'package:get_it/get_it.dart';
final GetIt locator = GetIt.instance;
void setupLocator() {
locator.registerLazySingleton<UserRemoteDataSource>(
() => UserRemoteDataSource(),
);
locator.registerLazySingleton<UserRepository>(
() => UserRepositoryImpl(locator<UserRemoteDataSource>()),
);
locator.registerFactory<GetUserProfileUseCase>(
() => GetUserProfileUseCase(locator<UserRepository>()),
);
}setupLocator() dipanggil sekali di main() sebelum runApp. Saat GetUserProfileUseCase diminta, GetIt otomatis menyediakan UserRepositoryImpl beserta data source yang dibutuhkan. Manfaat utamanya muncul saat testing: kita bisa mengganti registrasi dengan mock lewat locator.reset() tanpa menyentuh kode produksi.
Menangani Error secara Fungsional dengan Dartz Either
Dart mengandalkan try/catch, tetapi exception yang bocor ke UI menyulitkan penanganan yang konsisten. Setiap pemanggil harus tahu jenis exception apa yang mungkin muncul, dan kesalahan yang terlewat baru terlihat saat runtime.
Either<Failure, Success> merepresentasikan dua kemungkinan hasil tanpa melempar exception. Left menyimpan Failure, sedangkan Right menyimpan nilai sukses. Setiap nilai Either selalu membawa salah satu dari dua label, sehingga pemanggil tahu apakah isinya berupa hasil atau alasan kegagalan.
Method fold memaksa pemanggil menangani kedua cabang: sukses dan gagal. Setiap use case mengembalikan Either, sehingga keputusan error ditangani di satu tempat. Mapping error dari data layer menjadi Failure yang bermakna, bukan pesan teknis mentah, membuat pesan ke pengguna lebih terkendali. Pendekatan ini menempatkan penanganan error sebagai bagian dari kontrak, bukan kejutan yang muncul di runtime.
class GetUserProfileUseCase {
final UserRepository repository;
GetUserProfileUseCase(this.repository);
Future<Either<Failure, User>> call(String id) async {
final result = await repository.getUserProfile(id);
return result;
}
}
String formatProfileResult(Either<Failure, User> result) {
return result.fold(
(failure) => 'Error: ${failure.message}',
(user) => 'Halo, ${user.name}',
);
}Fungsi formatProfileResult menunjukkan alur fold. Cabang Left menghasilkan pesan error yang sudah diformat, sedangkan cabang Right menghasilkan sapaan dengan nama pengguna. Expected output saat repository mengembalikan Left adalah teks "Error: Gagal memuat profil dari server", dan saat Right adalah "Halo, nama pengguna".
Merangkai Seluruh Lapisan di Widget Flutter dan Menjaga Alur Data
Widget hanya berperan sebagai presentation: memanggil use case lewat GetIt dan merender state. Alur satu request berjalan berurutan. Widget memanggil use case, use case memanggil repository, dan repository mengambil data dari data source. Widget tidak mengetahui detail jaringan, JSON, atau tipe exception.
Berikut contoh widget yang memanggil GetUserProfileUseCase dan memetakan hasil Either menjadi tampilan:
class ProfilePage extends StatefulWidget {
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
final useCase = locator<GetUserProfileUseCase>();
String _status = 'Memuat...';
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final result = await useCase('user-1');
setState(() {
_status = result.fold(
(failure) => failure.message,
(user) => user.name,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(body: Center(child: Text(_status)));
}
}Method _load() memanggil use case dengan satu perintah. Hasil Either dipetakan lewat fold, sehingga Right menjadi nama pengguna dan Left menjadi pesan error. Expected output UI menampilkan nama saat request berhasil, atau pesan error saat gagal, tanpa widget pernah menyentuh http.
Beberapa checklist menjaga arsitektur tetap rapi. Satu use case memegang satu tanggung jawab, widget tidak membuat instance manual, dan setiap hasil Either selalu ditangani. Untuk pengujian, unit test cukup mengganti UserRepository dengan mock lewat constructor atau GetIt. Logging sederhana di data source dan pemantauan failure rate dari result Left membantu kita mendeteksi masalah lebih awal.
Perdalam arsitektur Flutter yang siap production bersama Bootcamp Mobile Development Rumah Coding. Kita membahas struktur lapisan, dependency injection, dan error handling dengan studi kasus nyata, dari setup sampai deploy.
Kursus Terkait
Advanced Flutter State Management with BLoC
Master advanced Flutter state management by building a production-ready application. This intermediate course uses a top-down, problem-driven approach, plunging you into real-world engineering challenges. You will learn to architect scalable applications, handle complex reactive states, manage multi-BLoC communication, synchronize real-time data, and implement optimistic UI updates using industry-standard BLoC patterns.
TaskSync: Real-Time Collaborative Task Manager
- Role-Based Authentication: Secure login and session management, dynamically reflecting user states across the entire application.
- Real-Time Task Board: A Kanban-style board that instantly updates across all devices when any team member creates, moves, or deletes a task.
- Advanced Search & Filtering: High-performance local search with event debouncing to prevent unnecessary API calls.
Flutter Mobile Development
Launch your mobile development journey with this immersive, project-based Flutter course. Designed specifically for beginners, this program takes you from coding fundamentals in Dart to deploying a fully functional mobile app. You will learn to craft beautiful, responsive UIs, handle global state management, and integrate cloud backends. By the end of the course, you will have built a real-world, cloud-synced application from scratch.
DailyQuest: Gamified Habit Tracker
- Secure Authentication: User registration and login functionality using email and password.
- Cloud Data Synchronization: Real-time database integration (using Supabase or Firebase) to securely store and retrieve user habits.
- Full CRUD Operations: The ability for users to Create, Read, Update, and Delete their daily tasks and habits.
Artikel Terkait
Implementasi Biometric Authentication di Flutter dengan Local Auth: Fingerprint dan Face ID
Membangun Aplikasi E-Commerce dengan Flutter: State Management BLoC, Checkout Flow, dan Payment Gateway Integration