Flutter
Repository Pattern dengan Firebase
Panduan merapikan kode Flutter Firebase dengan Repository Pattern: AuthRepository, TaskRepository, Firestore service, error handling, dan integrasi ke Provider, Riverpod, atau Cubit.
Repository Pattern dengan Firebase
Materi ini adalah lanjutan dari:
- Repository Pattern Sederhana
- Firebase Authentication untuk Pemula
- Cloud Firestore Dasar
- Firestore CRUD untuk Pemula
- Task Manager Online per User
- Firestore Security Rules Dasar
Di materi sebelumnya kita sudah bisa membuat aplikasi Task Manager online. Sekarang kita akan merapikan struktur kodenya agar lebih mudah dikembangkan.
Masalah yang ingin kita selesaikan:
- kode Firebase terlalu banyak ditulis di halaman UI
- logic login bercampur dengan logic tampilan
- logic Firestore bercampur dengan widget
- sulit mengganti sumber data
- sulit testing
- sulit membaca alur aplikasi ketika project makin besar
Solusinya adalah memakai Repository Pattern.
1. Gambaran Besar
Repository Pattern membantu memisahkan sumber data dari UI.
Alur yang kita inginkan:
UI Page
-> State Management
-> Repository
-> Firebase Auth / Cloud Firestore
UI tidak perlu tahu detail:
- collection Firestore apa yang dipakai
- query Firestore seperti apa
- cara parsing document
- cara menangani FirebaseException
- cara mengambil uid user login
UI cukup memanggil method yang jelas:
await taskRepository.addTask('Belajar Repository');
Repository yang mengurus detail Firebase di belakang layar.
2. Kenapa Tidak Langsung Panggil Firebase di UI?
Untuk project kecil, kode seperti ini memang cepat:
await FirebaseFirestore.instance.collection('tasks').add({
'title': title,
'isDone': false,
'userId': FirebaseAuth.instance.currentUser!.uid,
});
Tetapi kalau ditulis langsung di banyak halaman, masalah mulai muncul.
Contoh:
- logic
currentUser!.uidberulang di banyak tempat - nama collection
taskstersebar di banyak file - error handling tidak konsisten
- query list task ditulis ulang
- validasi data tersebar
- sulit membuat mock data saat testing
Repository membuat detail-detail itu terkumpul di satu tempat.
3. Tanggung Jawab Setiap Layer
Pembagian layer sederhana:
Page / Widget
Tugas: menampilkan UI dan menerima input user
State Management
Tugas: mengatur loading, data, error, dan event UI
Repository
Tugas: menyediakan data untuk aplikasi
Firebase
Tugas: menyimpan data dan melakukan autentikasi
Contoh pembagian:
| Layer | Contoh File | Tanggung Jawab |
|---|---|---|
| UI | online_task_page.dart | menampilkan list task, input, tombol |
| State | task_controller.dart / task_notifier.dart / task_cubit.dart | loading, error, state task |
| Repository | task_repository.dart | add, update, delete, watch task |
| Data source | Firebase Auth, Firestore | login user, simpan data |
| Model | task.dart | bentuk data aplikasi |
Dengan pola ini, setiap file punya tugas yang lebih jelas.
4. Struktur Folder yang Direkomendasikan
Untuk project belajar, struktur ini sudah cukup rapi:
lib/
core/
errors/
app_exception.dart
features/
auth/
repositories/
auth_repository.dart
pages/
login_page.dart
register_page.dart
tasks/
models/
task.dart
repositories/
task_repository.dart
pages/
task_page.dart
state/
task_controller.dart
Kalau ingin lebih sederhana:
lib/
models/
task.dart
repositories/
auth_repository.dart
task_repository.dart
pages/
login_page.dart
task_page.dart
state/
task_controller.dart
Untuk pemula, struktur kedua lebih mudah. Setelah project membesar, bisa pindah ke struktur features.
5. Membuat AppException
Firebase bisa menghasilkan banyak jenis error. Agar UI tidak langsung bergantung ke FirebaseException, kita bisa membuat exception sendiri.
Buat file:
lib/core/errors/app_exception.dart
Isi:
class AppException implements Exception {
final String message;
const AppException(this.message);
@override
String toString() => message;
}
Manfaatnya:
- UI menerima pesan error yang lebih rapi
- repository bisa mengubah error Firebase menjadi error aplikasi
- error handling lebih konsisten
Contoh:
throw const AppException('Judul task tidak boleh kosong');
6. Membuat AuthRepository
AuthRepository bertugas mengurus semua hal terkait Firebase Auth.
Buat file:
lib/repositories/auth_repository.dart
Isi:
import 'package:firebase_auth/firebase_auth.dart';
import '../core/errors/app_exception.dart';
class AuthRepository {
final FirebaseAuth _auth;
AuthRepository({
FirebaseAuth? auth,
}) : _auth = auth ?? FirebaseAuth.instance;
Stream<User?> authStateChanges() {
return _auth.authStateChanges();
}
User? get currentUser {
return _auth.currentUser;
}
String get currentUserId {
final user = _auth.currentUser;
if (user == null) {
throw const AppException('User belum login');
}
return user.uid;
}
Future<void> login({
required String email,
required String password,
}) async {
try {
await _auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
} on FirebaseAuthException catch (error) {
throw AppException(_mapAuthError(error));
}
}
Future<void> register({
required String email,
required String password,
}) async {
try {
await _auth.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
);
} on FirebaseAuthException catch (error) {
throw AppException(_mapAuthError(error));
}
}
Future<void> logout() async {
await _auth.signOut();
}
String _mapAuthError(FirebaseAuthException error) {
switch (error.code) {
case 'invalid-email':
return 'Format email tidak valid';
case 'user-not-found':
return 'User tidak ditemukan';
case 'wrong-password':
return 'Password salah';
case 'email-already-in-use':
return 'Email sudah digunakan';
case 'weak-password':
return 'Password terlalu lemah';
default:
return error.message ?? 'Terjadi kesalahan autentikasi';
}
}
}
Penjelasan:
authStateChangesdipakai untuk memantau status logincurrentUsermengambil user login saat inicurrentUserIdmengambil uid dan melempar error jika belum loginloginmemanggil Firebase Authregistermembuat akun barulogoutkeluar dari akun_mapAuthErrormengubah error Firebase menjadi pesan yang mudah dibaca
Dengan ini, halaman login tidak perlu langsung memanggil FirebaseAuth.instance.
7. Membuat Model Task
Buat file:
lib/models/task.dart
Isi:
import 'package:cloud_firestore/cloud_firestore.dart';
class Task {
final String id;
final String title;
final bool isDone;
final String userId;
final DateTime? createdAt;
final DateTime? updatedAt;
const Task({
required this.id,
required this.title,
required this.isDone,
required this.userId,
this.createdAt,
this.updatedAt,
});
factory Task.fromFirestore(DocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data();
if (data == null) {
throw Exception('Data task kosong');
}
return Task(
id: doc.id,
title: data['title'] ?? '',
isDone: data['isDone'] ?? false,
userId: data['userId'] ?? '',
createdAt: (data['createdAt'] as Timestamp?)?.toDate(),
updatedAt: (data['updatedAt'] as Timestamp?)?.toDate(),
);
}
Map<String, dynamic> toCreateJson() {
return {
'title': title,
'isDone': isDone,
'userId': userId,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
};
}
Map<String, dynamic> toUpdateJson() {
return {
'title': title,
'isDone': isDone,
'userId': userId,
'updatedAt': FieldValue.serverTimestamp(),
};
}
Task copyWith({
String? id,
String? title,
bool? isDone,
String? userId,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return Task(
id: id ?? this.id,
title: title ?? this.title,
isDone: isDone ?? this.isDone,
userId: userId ?? this.userId,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
Model tetap bertugas mengubah data Firestore menjadi object Dart dan sebaliknya.
Yang perlu diingat:
fromFirestoredipakai saat membaca datatoCreateJsondipakai saat membuat tasktoUpdateJsondipakai saat update taskcopyWithmembantu mengubah sebagian data tanpa merusak object lama
8. Membuat TaskRepository
TaskRepository bertugas menyediakan data task untuk aplikasi.
Buat file:
lib/repositories/task_repository.dart
Isi:
import 'package:cloud_firestore/cloud_firestore.dart';
import '../core/errors/app_exception.dart';
import '../models/task.dart';
import 'auth_repository.dart';
class TaskRepository {
final FirebaseFirestore _firestore;
final AuthRepository _authRepository;
TaskRepository({
FirebaseFirestore? firestore,
AuthRepository? authRepository,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_authRepository = authRepository ?? AuthRepository();
CollectionReference<Map<String, dynamic>> get _tasks {
return _firestore.collection('tasks');
}
Stream<List<Task>> watchMyTasks() {
final uid = _authRepository.currentUserId;
return _tasks
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs.map(Task.fromFirestore).toList();
});
}
Future<void> addTask(String title) async {
final trimmedTitle = title.trim();
if (trimmedTitle.isEmpty) {
throw const AppException('Judul task tidak boleh kosong');
}
final task = Task(
id: '',
title: trimmedTitle,
isDone: false,
userId: _authRepository.currentUserId,
);
try {
await _tasks.add(task.toCreateJson());
} on FirebaseException catch (error) {
throw AppException(_mapFirestoreError(error));
}
}
Future<void> updateTask(Task task) async {
if (task.title.trim().isEmpty) {
throw const AppException('Judul task tidak boleh kosong');
}
try {
await _tasks.doc(task.id).update(task.toUpdateJson());
} on FirebaseException catch (error) {
throw AppException(_mapFirestoreError(error));
}
}
Future<void> toggleTask(Task task) async {
final updatedTask = task.copyWith(isDone: !task.isDone);
await updateTask(updatedTask);
}
Future<void> deleteTask(String taskId) async {
try {
await _tasks.doc(taskId).delete();
} on FirebaseException catch (error) {
throw AppException(_mapFirestoreError(error));
}
}
String _mapFirestoreError(FirebaseException error) {
switch (error.code) {
case 'permission-denied':
return 'Tidak punya akses ke data ini';
case 'unavailable':
return 'Koneksi ke Firebase sedang bermasalah';
case 'not-found':
return 'Data tidak ditemukan';
default:
return error.message ?? 'Terjadi kesalahan Firestore';
}
}
}
Penjelasan:
TaskRepositorytidak mengambil uid langsung dari Firebase Auth- uid diambil lewat
AuthRepository - semua query task milik user ditulis di repository
- UI tidak perlu tahu detail collection
tasks - error Firebase diubah menjadi
AppException
Ini membuat kode lebih bersih dan lebih mudah dikembangkan.
9. Kenapa TaskRepository Memakai AuthRepository?
Task milik user harus selalu punya userId.
Tanpa AuthRepository, biasanya repository akan menulis ini:
FirebaseAuth.instance.currentUser!.uid
Masalahnya:
TaskRepositoryjadi bergantung langsung ke Firebase Auth- logic auth tersebar
- testing lebih sulit
Dengan AuthRepository, pengambilan user login menjadi lebih terpusat:
final uid = _authRepository.currentUserId;
Nanti kalau cara login berubah, kita cukup mengubah AuthRepository.
10. Contoh Pemakaian di UI Sederhana
Contoh paling sederhana tanpa state management khusus:
class TaskPage extends StatefulWidget {
const TaskPage({super.key});
@override
State<TaskPage> createState() => _TaskPageState();
}
class _TaskPageState extends State<TaskPage> {
final TaskRepository _repository = TaskRepository();
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _addTask() async {
try {
await _repository.addTask(_controller.text);
_controller.clear();
} catch (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Task')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(controller: _controller),
),
ElevatedButton(
onPressed: _addTask,
child: const Text('Tambah'),
),
],
),
),
Expanded(
child: StreamBuilder<List<Task>>(
stream: _repository.watchMyTasks(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text(snapshot.error.toString()));
}
final tasks = snapshot.data ?? [];
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(
title: Text(task.title),
leading: Checkbox(
value: task.isDone,
onChanged: (_) => _repository.toggleTask(task),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _repository.deleteTask(task.id),
),
);
},
);
},
),
),
],
),
);
}
}
Ini sudah lebih rapi dibanding UI yang langsung memanggil Firestore.
Tetapi untuk project lebih besar, sebaiknya repository dipakai lewat state management.
11. Integrasi dengan Provider
Contoh ChangeNotifier sederhana:
import 'package:flutter/material.dart';
import '../models/task.dart';
import '../repositories/task_repository.dart';
class TaskProvider extends ChangeNotifier {
final TaskRepository _repository;
TaskProvider({
TaskRepository? repository,
}) : _repository = repository ?? TaskRepository();
bool isLoading = false;
String? errorMessage;
Stream<List<Task>> watchTasks() {
return _repository.watchMyTasks();
}
Future<void> addTask(String title) async {
isLoading = true;
errorMessage = null;
notifyListeners();
try {
await _repository.addTask(title);
} catch (error) {
errorMessage = error.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> toggleTask(Task task) async {
try {
await _repository.toggleTask(task);
} catch (error) {
errorMessage = error.toString();
notifyListeners();
}
}
Future<void> deleteTask(String taskId) async {
try {
await _repository.deleteTask(taskId);
} catch (error) {
errorMessage = error.toString();
notifyListeners();
}
}
}
Provider menjadi penghubung antara UI dan repository.
UI memanggil:
context.read<TaskProvider>().addTask(title);
Repository tetap menjadi tempat logic Firestore.
12. Integrasi dengan Riverpod
Contoh provider repository:
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/task.dart';
import '../repositories/auth_repository.dart';
import '../repositories/task_repository.dart';
final authRepositoryProvider = Provider<AuthRepository>((ref) {
return AuthRepository();
});
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
final authRepository = ref.watch(authRepositoryProvider);
return TaskRepository(
authRepository: authRepository,
);
});
final myTasksProvider = StreamProvider<List<Task>>((ref) {
final repository = ref.watch(taskRepositoryProvider);
return repository.watchMyTasks();
});
Contoh pemakaian di UI:
final tasksAsync = ref.watch(myTasksProvider);
return tasksAsync.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text(error.toString()),
data: (tasks) {
return ListView.builder(
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return ListTile(title: Text(task.title));
},
);
},
);
Riverpod cocok karena dependency seperti AuthRepository dan TaskRepository bisa disusun dengan rapi.
13. Integrasi dengan Cubit
Contoh state:
class TaskState {
final bool isLoading;
final String? errorMessage;
const TaskState({
this.isLoading = false,
this.errorMessage,
});
TaskState copyWith({
bool? isLoading,
String? errorMessage,
}) {
return TaskState(
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
}
Contoh Cubit:
import 'package:flutter_bloc/flutter_bloc.dart';
import '../models/task.dart';
import '../repositories/task_repository.dart';
class TaskCubit extends Cubit<TaskState> {
final TaskRepository _repository;
TaskCubit({
TaskRepository? repository,
}) : _repository = repository ?? TaskRepository(),
super(const TaskState());
Stream<List<Task>> watchTasks() {
return _repository.watchMyTasks();
}
Future<void> addTask(String title) async {
emit(state.copyWith(isLoading: true, errorMessage: null));
try {
await _repository.addTask(title);
emit(state.copyWith(isLoading: false, errorMessage: null));
} catch (error) {
emit(state.copyWith(
isLoading: false,
errorMessage: error.toString(),
));
}
}
Future<void> toggleTask(Task task) async {
try {
await _repository.toggleTask(task);
} catch (error) {
emit(state.copyWith(errorMessage: error.toString()));
}
}
}
Di Cubit, repository membuat logic data tetap berada di luar Cubit.
Cubit fokus pada:
- loading
- error
- event dari UI
- state aplikasi
14. Repository dan Stream
Firestore sering dipakai dengan Stream.
Contoh:
Stream<List<Task>> watchMyTasks()
Kenapa return stream dari repository?
Karena Firestore bisa memberi data realtime. Saat data berubah, UI otomatis menerima data baru.
Repository tidak harus menyimpan list task sendiri. Repository cukup menyediakan stream.
State management atau UI yang memutuskan cara menampilkan stream itu.
15. Repository dan Future
Untuk aksi sekali jalan, gunakan Future.
Contoh:
Future<void> addTask(String title)
Future<void> updateTask(Task task)
Future<void> deleteTask(String taskId)
Kenapa bukan stream?
Karena aksi seperti add, update, delete hanya dijalankan satu kali lalu selesai.
Ringkasnya:
- baca realtime:
Stream - aksi create/update/delete:
Future
16. Error Handling di Repository
Repository adalah tempat yang bagus untuk mengubah error teknis menjadi error yang dipahami aplikasi.
Contoh Firebase error:
cloud_firestore/permission-denied
Pesan untuk user:
Tidak punya akses ke data ini
Contoh:
String _mapFirestoreError(FirebaseException error) {
switch (error.code) {
case 'permission-denied':
return 'Tidak punya akses ke data ini';
case 'unavailable':
return 'Koneksi ke Firebase sedang bermasalah';
default:
return 'Terjadi kesalahan Firestore';
}
}
Dengan pola ini, UI tidak perlu tahu semua kode error Firebase.
17. Dependency Injection Sederhana
Perhatikan constructor ini:
TaskRepository({
FirebaseFirestore? firestore,
AuthRepository? authRepository,
}) : _firestore = firestore ?? FirebaseFirestore.instance,
_authRepository = authRepository ?? AuthRepository();
Ini adalah dependency injection sederhana.
Manfaat:
- default tetap mudah dipakai
- saat testing, dependency bisa diganti
- repository tidak terlalu kaku
Contoh saat production:
final repository = TaskRepository();
Contoh saat testing:
final repository = TaskRepository(
firestore: fakeFirestore,
authRepository: fakeAuthRepository,
);
Materi testing akan lebih mudah jika struktur seperti ini sudah dipakai.
18. Kapan Repository Perlu Dibuat?
Repository perlu dibuat ketika:
- data dipakai di lebih dari satu halaman
- logic query mulai panjang
- ada lebih dari satu sumber data
- ada error handling khusus
- aplikasi memakai Auth
- aplikasi memakai API atau Firebase
- project ingin mudah dites
- project akan dikembangkan jangka panjang
Repository belum wajib kalau:
- aplikasi sangat kecil
- hanya satu halaman
- data tidak kompleks
- sedang membuat prototype cepat
Untuk project belajar yang ingin menjadi portfolio, repository sangat disarankan.
19. Kesalahan Umum Pemula
Repository terlalu banyak mengurus UI
Repository tidak perlu tahu:
BuildContextSnackBarNavigator- warna button
- loading widget
Itu tugas UI.
UI masih memanggil Firebase langsung
Kalau sudah memakai repository, usahakan UI tidak lagi memanggil:
FirebaseFirestore.instance
FirebaseAuth.instance
Panggil repository saja.
Semua logic dimasukkan ke satu repository
Pisahkan berdasarkan domain:
AuthRepositoryTaskRepositoryProfileRepositoryNotificationRepository
Jangan semua digabung menjadi FirebaseRepository.
Error mentah ditampilkan langsung
Hindari menampilkan error teknis panjang ke user.
Ubah menjadi pesan yang lebih manusiawi:
Tidak punya akses ke data ini
20. Checklist
Setelah mempelajari materi ini, pastikan kamu bisa:
- menjelaskan fungsi Repository Pattern
- membuat
AuthRepository - membuat
TaskRepository - memisahkan UI dari Firebase Auth
- memisahkan UI dari Cloud Firestore
- mengambil
uiduser login lewat repository - membuat query task per user di repository
- menangani error Firebase di repository
- memakai repository dari Provider
- memakai repository dari Riverpod
- memakai repository dari Cubit
- memahami kapan repository perlu dibuat
21. Kesimpulan
Repository Pattern membuat aplikasi Flutter Firebase lebih rapi.
Poin penting:
- UI fokus pada tampilan
- state management fokus pada state
- repository fokus pada data
- Firebase tetap berada di layer data
- error handling lebih konsisten
- kode lebih mudah dikembangkan
- testing lebih mudah disiapkan
Setelah memakai repository, project Task Manager online kita sudah punya struktur yang jauh lebih siap untuk dikembangkan menjadi aplikasi yang lebih serius.
22. Selanjutnya
Materi berikutnya yang cocok adalah Offline-first Sync Local Storage + Firestore.
Di materi itu kita akan membahas bagaimana aplikasi tetap bisa dipakai saat koneksi internet tidak stabil, lalu data disinkronkan lagi ke Firestore.
Referensi resmi: