Repository Pattern #
Ketika kita mengembangkan aplikasi Flutter berskala menengah hingga besar, mengelola dari mana data berasal dan bagaimana data tersebut didistribusikan ke antarmuka pengguna (UI) dapat menjadi sangat rumit. Kebiasaan menuliskan pemanggilan API secara langsung di dalam controller state management (seperti Bloc, Riverpod Notifier, atau Provider) adalah jalan pintas yang berbahaya. Hal ini membuat logika bisnis kita terikat sangat erat dengan pustaka pihak ketiga (seperti Dio atau Hive) dan mempersulit pembuatan pengujian unit (unit testing).
Untuk mengatasi masalah tersebut, dunia rekayasa perangkat lunak mengenalkan Repository Pattern. Repository Pattern adalah pola desain arsitektur yang bertindak sebagai lapisan abstraksi antara logika bisnis aplikasi dengan sumber data mentah (data sources). Dengan pola ini, layer state management tidak perlu tahu apakah data yang kita minta diambil dari jaringan internet (remote API), dari basis data lokal (local database), atau dari memori cache sementara. State management hanya memanggil metode di repository dan menerima objek domain (domain entity) yang bersih dan siap saji.
Dalam dokumen ini, kita akan membedah secara mendalam arsitektur Repository Pattern, merancang struktur direktori yang bersih, memisahkan objek transfer data (DTO) dari model bisnis, hingga mempelajari cara menulis unit test yang cepat tanpa bergantung pada koneksi internet.
Mengapa Kita Membutuhkan Repository Pattern? #
Untuk memahami pentingnya pola ini, mari kita bandingkan skenario pengembangan sebelum dan setelah menerapkan Repository Pattern:
TINDAKAN TANPA REPOSITORY PATTERN:
UI Widget --> State Controller (Bloc/Notifier) --> Panggilan Dio Jaringan --> Parse JSON --> Rebuild UI
Masalah yang Muncul:
✗ Logika pemanggilan jaringan (seperti penanganan URL dan header) tersebar di banyak berkas state controller.
✗ Jika kita ingin mengganti pustaka HTTP Client (misal dari Dio ke GraphQL), kita harus mengubah baris kode di banyak controller.
✗ Sangat sulit menulis unit test karena controller secara fisik melakukan hit ke internet (membutuhkan internet saat testing).
✗ Tidak ada tempat terpusat untuk mengelola caching data secara transparan.
✗ Duplikasi kode terjadi jika banyak fitur berbeda membutuhkan data yang sama.
TINDAKAN DENGAN REPOSITORY PATTERN:
UI Widget --> State Controller --> Repository Interface
│
┌───────────────────────┴───────────────────────┐
▼ ▼
Remote Data Source (Dio API) Local Data Source (Hive Cache)
Keuntungan yang Kita Dapatkan:
✓ Seluruh logika networking terpusat di satu layer Data Source yang terisolasi.
✓ Logika caching dapat ditambahkan atau dimodifikasi tanpa mengubah kode di layer UI atau State Controller.
✓ Pengujian sangat mudah dilakukan: kita cukup mengganti repository asli dengan repository tiruan (Mock) saat test berjalan.
✓ Satu repository dapat digunakan kembali secara konsisten oleh banyak fitur UI yang berbeda.
Berikut adalah diagram alir pertukaran data yang diorkestrasi oleh Repository Pattern untuk memisahkan data lokal, remote, dan kebutuhan UI kita secara teratur:
graph TD
classDef default stroke:#333,stroke-width:2px;
UI["Layer UI / State Management"] -->|"1. Panggil getProduk()"| Repo["Repository Interface / Implementasi"]
Repo -->|"2. Cek Cache / Ambil Cache"| Local["Local Data Source (Hive/Database)"]
Repo -->|"3. Jika Expired, Ambil API"| Remote["Remote Data Source (Dio/API)"]
Remote -. "4. Kembalikan DTO" .-> Repo
Local -. "5. Kembalikan DTO Cache" .-> Repo
Repo -->|"6. Map DTO ke Domain Entity"| Repo
Repo -. "7. Kembalikan Domain Entity" .-> UIStruktur Direktori Clean Architecture #
Di dalam pengembangan profesional, Repository Pattern biasanya diterapkan berdampingan dengan prinsip Clean Architecture atau Feature-First/Layer-First Structure.
Berikut adalah contoh susunan direktori proyek Flutter kita yang terstandarisasi untuk mengisolasi setiap bagian secara modular:
lib/
features/
produk/
data/
datasources/
produk_remote_data_source.dart ← Mengurusi HTTP Call dengan Dio
produk_local_data_source.dart ← Mengurusi Caching dengan Hive/SQLite
models/
produk_dto.dart ← Kelas Model JSON (Data Transfer Object)
repositories/
produk_repository_impl.dart ← Implementasi konkret dari kontrak interface
domain/
entities/
produk.dart ← Objek bisnis murni bebas dari framework/library
repositories/
produk_repository.dart ← Kontrak Kelas Abstrak (Interface)
presentation/
screens/
halaman_produk_screen.dart ← Widget UI visual
providers/
produk_notifier.dart ← Pengendali State (Riverpod/Bloc)
Dengan susunan ini, kode kita dibagi menjadi tiga wilayah besar: domain (berisi aturan bisnis murni), data (berisi detail teknis database dan internet), dan presentation (berisi antarmuka pengguna).
Layer 1: Domain Entity (Model Bisnis Murni) #
Langkah awal kita adalah mendefinisikan Domain Entity. Domain Entity adalah representasi data asli yang digunakan oleh logika bisnis inti aplikasi kita. Karakteristik utama dari Domain Entity adalah kebersihan: kelas ini tidak boleh mengimpor pustaka pihak ketiga, tidak memiliki anotasi anotasi serialisasi JSON (seperti @JsonSerializable), dan tidak memiliki metode fromJson atau toJson.
// domain/entities/produk.dart
class Produk {
final String id;
final String nama;
final double harga;
final bool tersedia;
final String kategori;
final DateTime tanggalDibuat;
const Produk({
required this.id,
required this.nama,
required this.harga,
required this.tersedia,
required this.kategori,
required this.tanggalDibuat,
});
// Melakukan override operator == untuk membandingkan objek berdasarkan nilainya,
// bukan berdasarkan referensi alamat memorinya.
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Produk &&
runtimeType == other.runtimeType &&
id == other.id &&
nama == other.nama &&
harga == other.harga;
@override
int get hashCode => id.hashCode ^ nama.hashCode ^ harga.hashCode;
}
Layer 2: Repository Interface (Kontrak Data) #
Sebelum kita membuat kode untuk melakukan panggilan ke API, kita harus menetapkan kontrak kerja terlebih dahulu. Kontrak ini berupa Abstract Class yang mendefinisikan metode apa saja yang tersedia, apa parameter masukannya, dan apa data kembalian yang diharapkan.
Kontrak ini ditaruh di dalam direktori domain karena logika bisnis hanya peduli pada “apa” saja operasi yang bisa dilakukan, bukan “bagaimana” operasi itu dieksekusi secara teknis.
// domain/repositories/produk_repository.dart
import '../entities/produk.dart';
abstract class ProdukRepository {
/// Mengambil daftar produk secara paginasi.
/// Dapat melemparkan [JaringanException] jika koneksi internet terputus.
Future<List<Produk>> ambilProduk({
int halaman = 1,
int batas = 20,
String? kategori,
});
/// Mengambil detail satu produk berdasarkan ID uniknya.
Future<Produk> ambilDetailProduk(String id);
/// Menambahkan produk baru ke sistem.
Future<Produk> tambahProdukBaru(Produk produkBaru);
/// Memperbarui informasi produk yang sudah ada.
Future<Produk> perbaruiProduk(Produk produkEdit);
/// Menghapus produk dari sistem berdasarkan ID.
Future<void> hapusProduk(String id);
}
Layer 3: DTO (Data Transfer Object) & Mapper #
Di dalam layer data, format data yang dikirimkan oleh API sering kali tidak sesuai dengan nama field di kelas Produk domain kita. API mungkin mengirimkan properti dengan penulisan snake_case seperti category_name atau created_at.
Untuk itu, kita membuat DTO (Data Transfer Object). DTO adalah model data yang bertugas murni untuk menjalin komunikasi dengan parser JSON (dilengkapi anotasi @freezed dan fungsi fromJson).
Kita memisahkan DTO dari Domain Entity agar perubahan struktur database/API di backend tidak merusak logika bisnis utama di aplikasi kita. Kita juga menyediakan fungsi Mapper menggunakan extension untuk mengubah DTO menjadi Domain Entity dan sebaliknya.
// data/models/produk_dto.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/produk.dart';
part 'produk_dto.freezed.dart';
part 'produk_dto.g.dart';
@freezed
class ProdukDto with _$ProdukDto {
const factory ProdukDto({
required String id,
required String nama,
required double harga,
required bool tersedia,
@JsonKey(name: 'category_name') required String kategori,
@JsonKey(name: 'created_at') required DateTime tanggalDibuat,
}) = _ProdukDto;
factory ProdukDto.fromJson(Map<String, dynamic> json) =>
_$ProdukDtoFromJson(json);
}
// === MAPPER EXTENSION ===
// Digunakan untuk mengubah data DTO (layer data) menjadi Entity (layer domain)
extension ProdukDtoMapper on ProdukDto {
Produk toDomain() => Produk(
id: id,
nama: nama,
harga: harga,
tersedia: tersedia,
kategori: kategori,
tanggalDibuat: tanggalDibuat,
);
}
// Digunakan untuk mengubah Entity menjadi DTO sebelum dikirim ke API
extension ProdukEntityMapper on Produk {
ProdukDto toDto() => ProdukDto(
id: id,
nama: nama,
harga: harga,
tersedia: tersedia,
kategori: kategori,
tanggalDibuat: tanggalDibuat,
);
}
Layer 4: Remote Data Source (Logika API) #
RemoteDataSource bertanggung jawab penuh atas urusan teknis pemanggilan API jaringan menggunakan HTTP Client (Dio). Lapisan ini bertugas melakukan request, membaca status response, dan mengurai payload JSON menjadi objek DTO.
// data/datasources/produk_remote_data_source.dart
import 'package:dio/dio.dart';
import '../models/produk_dto.dart';
abstract class ProdukRemoteDataSource {
Future<List<ProdukDto>> dapatkanProduk({int halaman, int batas, String? kategori});
Future<ProdukDto> dapatkanDetailProduk(String id);
Future<ProdukDto> kirimProdukBaru(ProdukDto dto);
Future<void> hapusProduk(String id);
}
class ProdukRemoteDataSourceImpl implements ProdukRemoteDataSource {
final Dio _dio;
ProdukRemoteDataSourceImpl(this._dio);
@override
Future<List<ProdukDto>> dapatkanProduk({
int halaman = 1,
int batas = 20,
String? kategori,
}) async {
final response = await _dio.get(
'/produk',
queryParameters: {
'page': halaman,
'limit': batas,
if (kategori != null) 'kategori': kategori,
},
);
// Asumsikan respons sukses berformat: { "success": true, "data": [...] }
final List<dynamic> listData = response.data['data'];
return listData.map((json) => ProdukDto.fromJson(json)).toList();
}
@override
Future<ProdukDto> dapatkanDetailProduk(String id) async {
final response = await _dio.get('/produk/$id');
return ProdukDto.fromJson(response.data['data']);
}
@override
Future<ProdukDto> kirimProdukBaru(ProdukDto dto) async {
final response = await _dio.post(
'/produk',
data: dto.toJson(),
);
return ProdukDto.fromJson(response.data['data']);
}
@override
Future<void> hapusProduk(String id) async {
await _dio.delete('/produk/$id');
}
}
Layer 5: Local Data Source (Logika Cache) #
LocalDataSource bertugas mengurusi masalah penyimpanan offline di memori penyimpanan lokal perangkat menggunakan pustaka basis data seperti Hive. Pustaka ini biasanya dilengkapi dengan logika masa kedaluwarsa data cache (Time To Live / TTL).
// data/datasources/produk_local_data_source.dart
import 'package:hive/hive.dart';
import '../models/produk_dto.dart';
abstract class ProdukLocalDataSource {
Future<List<ProdukDto>?> ambilCacheProduk();
Future<void> simpanCacheProduk(List<ProdukDto> listDto);
Future<void> bersihkanCache();
}
class ProdukLocalDataSourceImpl implements ProdukLocalDataSource {
static const String _boxName = 'box_produk_cache';
static const String _cacheKey = 'key_list_produk';
static const String _timeKey = 'key_waktu_cache';
// Batas kedaluwarsa cache kita atur selama 15 menit
static const int _durasiExpiredMenit = 15;
@override
Future<List<ProdukDto>?> ambilCacheProduk() async {
final box = await Hive.openBox(_boxName);
final waktuSimpan = box.get(_timeKey) as DateTime?;
if (waktuSimpan == null) return null;
// Evaluasi apakah cache sudah kedaluwarsa
final selisihWaktu = DateTime.now().difference(waktuSimpan).inMinutes;
if (selisihWaktu > _durasiExpiredMenit) {
await bersihkanCache();
return null;
}
final dataMentah = box.get(_cacheKey) as List?;
if (dataMentah == null) return null;
return dataMentah
.map((item) => ProdukDto.fromJson(Map<String, dynamic>.from(item)))
.toList();
}
@override
Future<void> simpanCacheProduk(List<ProdukDto> listDto) async {
final box = await Hive.openBox(_boxName);
// Konversikan list DTO menjadi format JSON Map sebelum disimpan ke Hive
final listMap = listDto.map((dto) => dto.toJson()).toList();
await box.put(_cacheKey, listMap);
await box.put(_timeKey, DateTime.now());
}
@override
Future<void> bersihkanCache() async {
final box = await Hive.openBox(_boxName);
await box.clear();
}
}
Layer 6: Implementasi Repository (Konkret) #
ProdukRepositoryImpl adalah orkestrator utama. Kelas ini bertugas menggabungkan data dari RemoteDataSource dan LocalDataSource secara cerdas untuk disajikan ke UI. Di sinilah keputusan “apakah kita harus mengambil cache lokal atau mendownload ulang dari internet” terjadi.
UI atau state management tidak pernah tahu dari mana data berasal. Mereka hanya memanggil metode di kelas ini.
// data/repositories/produk_repository_impl.dart
import '../../domain/entities/produk.dart';
import '../../domain/repositories/produk_repository.dart';
import '../datasources/produk_remote_data_source.dart';
import '../datasources/produk_local_data_source.dart';
import '../models/produk_dto.dart';
class ProdukRepositoryImpl implements ProdukRepository {
final ProdukRemoteDataSource _remoteDataSource;
final ProdukLocalDataSource _localDataSource;
ProdukRepositoryImpl({
required ProdukRemoteDataSource remote,
required ProdukLocalDataSource local,
}) : _remoteDataSource = remote,
_localDataSource = local;
@override
Future<List<Produk>> ambilProduk({
int halaman = 1,
int batas = 20,
String? kategori,
}) async {
// 1. Jika pengguna membuka halaman pertama tanpa filter, coba muat dari cache lokal dulu
if (halaman == 1 && kategori == null) {
try {
final cacheLocal = await _localDataSource.ambilCacheProduk();
if (cacheLocal != null) {
// Konversikan list DTO lokal menjadi list Domain Entity
return cacheLocal.map((dto) => dto.toDomain()).toList();
}
} catch (_) {
// Abaikan error cache, lanjutkan mengunduh dari internet
}
}
// 2. Jika cache tidak tersedia atau kedaluwarsa, ambil dari internet (remote)
final dtosRemote = await _remoteDataSource.dapatkanProduk(
halaman: halaman,
batas: batas,
kategori: kategori,
);
// 3. Simpan data baru tersebut ke cache lokal untuk kunjungan berikutnya
if (halaman == 1 && kategori == null) {
try {
await _localDataSource.simpanCacheProduk(dtosRemote);
} catch (_) {
// Abaikan kegagalan caching agar aplikasi tidak crash
}
}
// 4. Kembalikan data dalam bentuk Domain Entity
return dtosRemote.map((dto) => dto.toDomain()).toList();
}
@override
Future<Produk> ambilDetailProduk(String id) async {
// Di sini kita bisa langsung memanggil remote source
final dto = await _remoteDataSource.dapatkanDetailProduk(id);
return dto.toDomain();
}
@override
Future<Produk> tambahProdukBaru(Produk produkBaru) async {
// Ubah domain entity menjadi DTO sebelum dikirim ke API
final dtoKirim = produkBaru.toDto();
final dtoHasil = await _remoteDataSource.kirimProdukBaru(dtoKirim);
// Bersihkan cache lokal karena data di server sudah berubah (stale)
await _localDataSource.bersihkanCache();
return dtoHasil.toDomain();
}
@override
Future<Produk> perbaruiProduk(Produk produkEdit) async {
// Proses update produk identik dengan tambah data
final dtoKirim = produkEdit.toDto();
final response = await _remoteDataSource.kirimProdukBaru(dtoKirim);
await _localDataSource.bersihkanCache();
return response.toDomain();
}
@override
Future<void> hapusProduk(String id) async {
await _remoteDataSource.hapusProduk(id);
await _localDataSource.bersihkanCache();
}
}
Integrasi Dependency Injection dengan Riverpod #
Agar instansi repository dapat diakses dengan mudah oleh controller state management tanpa merusak pola arsitektur, kita menyatukan seluruh layer menggunakan penyedia layanan Dependency Injection (DI) seperti Riverpod.
Kita membuat provider terpisah untuk setiap komponen dari layer terbawah hingga teratas:
// presentation/providers/produk_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../data/datasources/produk_remote_data_source.dart';
import '../../data/datasources/produk_local_data_source.dart';
import '../../data/repositories/produk_repository_impl.dart';
import '../../domain/repositories/produk_repository.dart';
import '../../domain/entities/produk.dart';
// 1. Menyediakan instansi Dio global
final dioProvider = Provider<Dio>((ref) {
return Dio(BaseOptions(baseUrl: 'https://api.tokokita.com/v1'));
});
// 2. Menyediakan instansi Remote Data Source
final produkRemoteDataSourceProvider = Provider<ProdukRemoteDataSource>((ref) {
final dio = ref.watch(dioProvider);
return ProdukRemoteDataSourceImpl(dio);
});
// 3. Menyediakan instansi Local Data Source
final produkLocalDataSourceProvider = Provider<ProdukLocalDataSource>((ref) {
return ProdukLocalDataSourceImpl();
});
// 4. Menyediakan instansi Repository (Mengekspos kelas kontrak/interface)
final produkRepositoryProvider = Provider<ProdukRepository>((ref) {
final remote = ref.watch(produkRemoteDataSourceProvider);
final local = ref.watch(produkLocalDataSourceProvider);
return ProdukRepositoryImpl(remote: remote, local: local);
});
// 5. Menyediakan Notifier State Management untuk dikonsumsi oleh UI Widget
class ProdukNotifier extends AutoDisposeAsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() async {
// Membaca data secara modular lewat repository
return ref.watch(produkRepositoryProvider).ambilProduk();
}
Future<void> tambahProduk(Produk baru) async {
state = const AsyncLoading();
try {
await ref.read(produkRepositoryProvider).tambahProdukBaru(baru);
// Invalidate self akan memicu pemanggilan ulang build() untuk me-refresh data
ref.invalidateSelf();
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
final daftarProdukProvider = AsyncNotifierProvider.autoDispose<ProdukNotifier, List<Produk>>(
ProdukNotifier.new,
);
Menguji (Testing) Logika Bisnis dengan Mock Repository #
Keunggulan terbesar dari penggunaan Repository Pattern adalah kemudahan pengujian. Karena state management hanya berkomunikasi dengan interface ProdukRepository abstrak, kita dapat membuat kelas tiruan (Mock) yang mengimplementasikan interface yang sama untuk keperluan testing tanpa perlu memanggil Dio atau memuat Hive database asli.
Mari kita buat pengujian unit test menggunakan Repository tiruan:
// test/produk_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../lib/domain/entities/produk.dart';
import '../../lib/domain/repositories/produk_repository.dart';
import '../../lib/presentation/providers/produk_providers.dart';
// 1. Membuat Mock Repository Tiruan secara manual yang mengimplementasikan ProdukRepository
class MockProdukRepository implements ProdukRepository {
final List<Produk> dataTiruan;
bool picuErrorJaringan;
MockProdukRepository({
required this.dataTiruan,
this.picuErrorJaringan = false,
});
@override
Future<List<Produk>> ambilProduk({int halaman = 1, int batas = 20, String? kategori}) async {
if (picuErrorJaringan) {
throw Exception('Sinyal internet buruk.');
}
// Simulasi delay latency jaringan internet
await Future.delayed(const Duration(milliseconds: 10));
return dataTiruan;
}
@override
Future<Produk> ambilDetailProduk(String id) async {
return dataTiruan.firstWhere((p) => p.id == id);
}
@override
Future<Produk> tambahProdukBaru(Produk produkBaru) async {
dataTiruan.add(produkBaru);
return produkBaru;
}
@override
Future<Produk> perbaruiProduk(Produk produkEdit) async {
return produkEdit;
}
@override
Future<void> hapusProduk(String id) async {
dataTiruan.removeWhere((p) => p.id == id);
}
}
// 2. Menjalankan skenario pengujian unit
void main() {
group('Pengujian Logika Bisnis ProdukNotifier', () {
late List<Produk> listMock;
setUp(() {
listMock = [
Produk(
id: '1',
nama: 'Laptop Gaming',
harga: 15000000.0,
tersedia: true,
kategori: 'elektronik',
tanggalDibuat: DateTime.now(),
),
Produk(
id: '2',
nama: 'Kaos Polos',
harga: 50000.0,
tersedia: true,
kategori: 'pakaian',
tanggalDibuat: DateTime.now(),
),
];
});
test('Harus memuat data produk dengan sukses menggunakan data mock', () async {
// Setup Container Riverpod dengan me-override repository asli menjadi mock repository
final container = ProviderContainer(
overrides: [
produkRepositoryProvider.overrideWithValue(
MockProdukRepository(dataTiruan: listMock),
),
],
);
addTearDown(container.dispose);
// Membaca state asinkron
final listHasil = await container.read(daftarProdukProvider.future);
expect(listHasil.length, equals(2));
expect(listHasil.first.nama, equals('Laptop Gaming'));
expect(listHasil.last.id, equals('2'));
});
test('Harus memancarkan status AsyncError jika terjadi gangguan internet', () async {
final container = ProviderContainer(
overrides: [
produkRepositoryProvider.overrideWithValue(
MockProdukRepository(dataTiruan: listMock, picuErrorJaringan: true),
),
],
);
addTearDown(container.dispose);
// Memastikan pemanggilan fungsi melempar Exception
expect(
() => container.read(daftarProdukProvider.future),
throwsA(isA<Exception>()),
);
});
});
}
Melalui unit test terisolasi seperti ini, kita dapat memvalidasi kebenaran logika bisnis aplikasi kita hanya dalam waktu kurang dari 1 detik di mesin pengembangan lokal tanpa dipengaruhi oleh status server API backend kita.
Ringkasan #
- Repository Pattern menyajikan lapisan pemisah bersih yang menyembunyikan detail dari mana data didapatkan (internet, memori cache, database lokal) dari layer UI dan State Management.
- Domain Entity adalah kelas model data bisnis murni yang steril dari dependensi library visual maupun fungsi deserialisasi JSON.
- DTO (Data Transfer Object) bertugas murni sebagai penampung data transisi jaringan yang disesuaikan dengan skema penulisan API server.
- Mapper Extension berfungsi sebagai konverter tipe data dua arah untuk menjembatani DTO dengan Domain Entity secara aman.
- Remote Data Source memegang kendali teknis eksekusi HTTP call, sedangkan Local Data Source bertugas mengurus penyimpanan cache luring (offline).
- Caching Orchestration ditaruh di dalam berkas implementasi repository murni, membebaskan widget UI dari urusan verifikasi masa kedaluwarsa data cache.
- Dependency Injection (DI) terpusat membantu mendistribusikan instansi repository dengan konfigurasi yang modular dan fleksibel.
- Unit Testing Kilat: Pemisahan antarmuka (interface) repository mempermudah penyuntikan data mock simulasi gangguan sinyal internet saat unit test dijalankan.
← Sebelumnya: JSON & Serialisasi Berikutnya: Error Handling →