Repository Pattern #
Repository Pattern adalah pola arsitektur yang menempatkan lapisan abstraksi antara logika bisnis dan sumber data. Dengan pattern ini, state management (Bloc, Riverpod, dll.) tidak perlu tahu apakah data berasal dari API, database lokal, atau cache — mereka hanya memanggil method di repository dan mendapatkan domain object yang sudah siap digunakan.
Mengapa Repository Pattern? #
TANPA Repository Pattern:
Bloc/Notifier → langsung panggil Dio → parse JSON → emit state
Masalah:
✗ Logika networking tersebar di banyak Bloc/Notifier
✗ Sulit diganti implementasinya (Dio → GraphQL)
✗ Sulit di-mock untuk unit test
✗ Tidak ada satu tempat untuk caching logic
✗ Duplikasi kode jika banyak fitur butuh data yang sama
DENGAN Repository Pattern:
Bloc/Notifier → Repository → RemoteDataSource (API)
→ LocalDataSource (cache)
Keuntungan:
✓ Logika networking terpusat di satu tempat
✓ Mudah diganti (mock saat test, beda implementasi saat produksi)
✓ Caching bisa ditambahkan tanpa mengubah Bloc/Notifier
✓ Satu repository bisa digunakan oleh banyak fitur
Struktur Lengkap #
lib/
features/
produk/
data/
datasources/
produk_remote_data_source.dart ← HTTP calls
produk_local_data_source.dart ← cache (Hive/SQLite)
models/
produk_dto.dart ← JSON model (DTO)
repositories/
produk_repository_impl.dart ← implementasi konkret
domain/
entities/
produk.dart ← domain entity (murni Dart)
repositories/
produk_repository.dart ← interface / abstract class
presentation/
screens/
produk_screen.dart
providers/
produk_provider.dart ← Riverpod/Bloc
Layer 1 — Domain Entity #
Domain entity adalah representasi murni data bisnis, bebas dari detail implementasi (tidak ada fromJson, tidak ada anotasi library):
// domain/entities/produk.dart
class Produk {
final String id;
final String nama;
final double harga;
final bool tersedia;
final String kategori;
final DateTime createdAt;
const Produk({
required this.id,
required this.nama,
required this.harga,
required this.tersedia,
required this.kategori,
required this.createdAt,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Produk && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;
}
Layer 2 — Repository Interface #
Interface mendefinisikan kontrak — operasi apa yang tersedia, input dan output apa yang diharapkan:
// domain/repositories/produk_repository.dart
abstract class ProdukRepository {
/// Ambil semua produk. Lempar [NetworkException] jika gagal.
Future<List<Produk>> getProduk({
int page = 1,
int limit = 20,
String? kategori,
});
/// Ambil detail produk berdasarkan ID.
/// Lempar [NotFoundException] jika tidak ditemukan.
Future<Produk> getProdukById(String id);
/// Tambah produk baru. Return produk yang sudah dibuat (dengan ID dari server).
Future<Produk> tambahProduk(Produk produk);
/// Update produk. Return produk yang sudah diupdate.
Future<Produk> updateProduk(Produk produk);
/// Hapus produk berdasarkan ID.
Future<void> hapusProduk(String id);
/// Cari produk berdasarkan query.
Future<List<Produk>> searchProduk(String query);
}
Layer 3 — DTO (Data Transfer Object) #
DTO adalah model yang menangani serialisasi JSON — terpisah dari domain entity:
// 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 createdAt,
}) = _ProdukDto;
factory ProdukDto.fromJson(Map<String, dynamic> json) =>
_$ProdukDtoFromJson(json);
}
// Extension untuk konversi DTO ↔ Domain Entity
extension ProdukDtoMapper on ProdukDto {
Produk toDomain() => Produk(
id: id,
nama: nama,
harga: harga,
tersedia: tersedia,
kategori: kategori,
createdAt: createdAt,
);
}
extension ProdukMapper on Produk {
ProdukDto toDto() => ProdukDto(
id: id,
nama: nama,
harga: harga,
tersedia: tersedia,
kategori: kategori,
createdAt: createdAt,
);
}
Layer 4 — Remote Data Source #
// data/datasources/produk_remote_data_source.dart
import 'package:dio/dio.dart';
abstract class ProdukRemoteDataSource {
Future<List<ProdukDto>> getProduk({int page, int limit, String? kategori});
Future<ProdukDto> getProdukById(String id);
Future<ProdukDto> tambahProduk(ProdukDto dto);
Future<ProdukDto> updateProduk(ProdukDto dto);
Future<void> hapusProduk(String id);
Future<List<ProdukDto>> searchProduk(String query);
}
class ProdukRemoteDataSourceImpl implements ProdukRemoteDataSource {
final Dio _dio;
ProdukRemoteDataSourceImpl(this._dio);
@override
Future<List<ProdukDto>> getProduk({
int page = 1,
int limit = 20,
String? kategori,
}) async {
final response = await _dio.get(
'/produk',
queryParameters: {
'page': page,
'limit': limit,
if (kategori != null) 'kategori': kategori,
},
);
final List<dynamic> data = response.data['data'];
return data.map((json) => ProdukDto.fromJson(json)).toList();
}
@override
Future<ProdukDto> getProdukById(String id) async {
final response = await _dio.get('/produk/$id');
return ProdukDto.fromJson(response.data['data']);
}
@override
Future<ProdukDto> tambahProduk(ProdukDto dto) async {
final response = await _dio.post('/produk', data: dto.toJson());
return ProdukDto.fromJson(response.data['data']);
}
@override
Future<ProdukDto> updateProduk(ProdukDto dto) async {
final response = await _dio.put('/produk/${dto.id}', data: dto.toJson());
return ProdukDto.fromJson(response.data['data']);
}
@override
Future<void> hapusProduk(String id) async {
await _dio.delete('/produk/$id');
}
@override
Future<List<ProdukDto>> searchProduk(String query) async {
final response = await _dio.get('/produk/search', queryParameters: {'q': query});
final List<dynamic> data = response.data['data'];
return data.map((json) => ProdukDto.fromJson(json)).toList();
}
}
Layer 5 — Local Data Source (Cache) #
// data/datasources/produk_local_data_source.dart
import 'package:hive/hive.dart';
abstract class ProdukLocalDataSource {
Future<List<ProdukDto>?> getCachedProduk();
Future<void> cacheProduk(List<ProdukDto> produk);
Future<ProdukDto?> getCachedProdukById(String id);
Future<void> clearCache();
}
class ProdukLocalDataSourceImpl implements ProdukLocalDataSource {
static const _boxName = 'produk_cache';
static const _listKey = 'all_produk';
static const _ttlMinutes = 30;
@override
Future<List<ProdukDto>?> getCachedProduk() async {
final box = await Hive.openBox(_boxName);
final cachedAt = box.get('cached_at') as DateTime?;
// Cache expired?
if (cachedAt == null ||
DateTime.now().difference(cachedAt).inMinutes > _ttlMinutes) {
return null;
}
final rawList = box.get(_listKey) as List?;
if (rawList == null) return null;
return rawList
.cast<Map<dynamic, dynamic>>()
.map((m) => ProdukDto.fromJson(Map<String, dynamic>.from(m)))
.toList();
}
@override
Future<void> cacheProduk(List<ProdukDto> produk) async {
final box = await Hive.openBox(_boxName);
await box.put(_listKey, produk.map((p) => p.toJson()).toList());
await box.put('cached_at', DateTime.now());
}
@override
Future<ProdukDto?> getCachedProdukById(String id) async {
final cached = await getCachedProduk();
return cached?.firstWhere((p) => p.id == id, orElse: () => throw Exception());
}
@override
Future<void> clearCache() async {
final box = await Hive.openBox(_boxName);
await box.clear();
}
}
Layer 6 — Repository Implementasi #
Repository yang mengorkestrasi remote dan local data source:
// data/repositories/produk_repository_impl.dart
class ProdukRepositoryImpl implements ProdukRepository {
final ProdukRemoteDataSource _remote;
final ProdukLocalDataSource _local;
ProdukRepositoryImpl({
required ProdukRemoteDataSource remote,
required ProdukLocalDataSource local,
}) : _remote = remote,
_local = local;
@override
Future<List<Produk>> getProduk({
int page = 1,
int limit = 20,
String? kategori,
}) async {
// Coba ambil dari cache dulu (hanya untuk halaman pertama)
if (page == 1 && kategori == null) {
final cached = await _local.getCachedProduk();
if (cached != null) {
return cached.map((dto) => dto.toDomain()).toList();
}
}
// Fetch dari remote
final dtos = await _remote.getProduk(
page: page,
limit: limit,
kategori: kategori,
);
// Simpan ke cache (hanya halaman pertama)
if (page == 1 && kategori == null) {
await _local.cacheProduk(dtos);
}
return dtos.map((dto) => dto.toDomain()).toList();
}
@override
Future<Produk> getProdukById(String id) async {
// Coba dari cache dulu
try {
final cached = await _local.getCachedProdukById(id);
if (cached != null) return cached.toDomain();
} catch (_) {}
// Fetch dari remote
final dto = await _remote.getProdukById(id);
return dto.toDomain();
}
@override
Future<Produk> tambahProduk(Produk produk) async {
final dto = await _remote.tambahProduk(produk.toDto());
await _local.clearCache(); // invalidate cache
return dto.toDomain();
}
@override
Future<Produk> updateProduk(Produk produk) async {
final dto = await _remote.updateProduk(produk.toDto());
await _local.clearCache();
return dto.toDomain();
}
@override
Future<void> hapusProduk(String id) async {
await _remote.hapusProduk(id);
await _local.clearCache();
}
@override
Future<List<Produk>> searchProduk(String query) async {
// Search selalu dari remote -- tidak di-cache
final dtos = await _remote.searchProduk(query);
return dtos.map((dto) => dto.toDomain()).toList();
}
}
Dependency Injection dengan Riverpod #
// providers/produk_provider.dart
// Infrastructure providers
final dioProvider = Provider<Dio>((ref) => DioClient().dio);
// Data source providers
final produkRemoteProvider = Provider<ProdukRemoteDataSource>((ref) {
return ProdukRemoteDataSourceImpl(ref.watch(dioProvider));
});
final produkLocalProvider = Provider<ProdukLocalDataSource>((ref) {
return ProdukLocalDataSourceImpl();
});
// Repository provider
final produkRepositoryProvider = Provider<ProdukRepository>((ref) {
return ProdukRepositoryImpl(
remote: ref.watch(produkRemoteProvider),
local: ref.watch(produkLocalProvider),
);
});
// State provider
class ProdukNotifier extends AsyncNotifier<List<Produk>> {
@override
Future<List<Produk>> build() {
return ref.watch(produkRepositoryProvider).getProduk();
}
Future<void> hapus(String id) async {
await ref.read(produkRepositoryProvider).hapusProduk(id);
ref.invalidateSelf();
}
}
final produkProvider = AsyncNotifierProvider<ProdukNotifier, List<Produk>>(
ProdukNotifier.new,
);
Mock Repository untuk Unit Test #
Keunggulan terbesar Repository Pattern: testing tanpa menyentuh jaringan atau database:
// test/mocks/mock_produk_repository.dart
class MockProdukRepository implements ProdukRepository {
final List<Produk> _produk;
bool shouldThrow;
MockProdukRepository({
List<Produk>? produk,
this.shouldThrow = false,
}) : _produk = produk ?? _defaultProduk;
static final _defaultProduk = [
Produk(id: '1', nama: 'Produk A', harga: 100000, tersedia: true,
kategori: 'elektronik', createdAt: DateTime.now()),
Produk(id: '2', nama: 'Produk B', harga: 200000, tersedia: false,
kategori: 'pakaian', createdAt: DateTime.now()),
];
@override
Future<List<Produk>> getProduk({int page = 1, int limit = 20, String? kategori}) async {
if (shouldThrow) throw NetworkException('Tidak ada koneksi');
await Future.delayed(const Duration(milliseconds: 10)); // simulasi latency
return _produk;
}
@override
Future<Produk> getProdukById(String id) async {
if (shouldThrow) throw NetworkException('Tidak ada koneksi');
return _produk.firstWhere((p) => p.id == id,
orElse: () => throw NotFoundException('Produk tidak ditemukan'));
}
// ... implementasi lainnya
}
// Penggunaan dalam test
void main() {
group('ProdukNotifier', () {
test('berhasil memuat produk', () async {
final container = ProviderContainer(
overrides: [
produkRepositoryProvider.overrideWith(
(_) => MockProdukRepository(),
),
],
);
final state = await container.read(produkProvider.future);
expect(state.length, 2);
expect(state.first.nama, 'Produk A');
});
test('menangani error jaringan', () async {
final container = ProviderContainer(
overrides: [
produkRepositoryProvider.overrideWith(
(_) => MockProdukRepository(shouldThrow: true),
),
],
);
await expectLater(
container.read(produkProvider.future),
throwsA(isA<NetworkException>()),
);
});
});
}
Ringkasan #
- Repository Pattern menempatkan lapisan abstraksi antara logika bisnis dan sumber data — state management tidak perlu tahu apakah data dari API, cache, atau database.
- Pisahkan domain entity (objek bisnis murni) dari DTO (model dengan serialisasi JSON). Konversi dilakukan di repository, bukan di state management.
- Definisikan interface repository sebagai abstract class — ini memungkinkan mock untuk testing dan penggantian implementasi tanpa mengubah kode yang menggunakannya.
- Remote data source bertanggung jawab untuk HTTP calls dan parsing response. Local data source untuk cache. Repository mengorkestrasi keduanya.
- Logika caching ada di repository — state management tidak perlu tahu apakah data segar dari API atau dari cache.
- Keunggulan terbesar pattern ini: testability — buat
MockRepositoryyang mengimplementasikan interface yang sama, inject ke test, dan test logika bisnis tanpa jaringan.- Dengan Riverpod, buat provider terpisah untuk setiap layer: Dio → DataSource → Repository → Notifier. Ini membuat setiap layer bisa di-override untuk testing.
← Sebelumnya: JSON & Serialisasi Berikutnya: Error Handling →