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.

27 dari 57 materi Architecture flutterfirebaserepository-patternarchitecturefirestoreauth

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!.uid berulang di banyak tempat
  • nama collection tasks tersebar 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:

LayerContoh FileTanggung Jawab
UIonline_task_page.dartmenampilkan list task, input, tombol
Statetask_controller.dart / task_notifier.dart / task_cubit.dartloading, error, state task
Repositorytask_repository.dartadd, update, delete, watch task
Data sourceFirebase Auth, Firestorelogin user, simpan data
Modeltask.dartbentuk 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:

  • authStateChanges dipakai untuk memantau status login
  • currentUser mengambil user login saat ini
  • currentUserId mengambil uid dan melempar error jika belum login
  • login memanggil Firebase Auth
  • register membuat akun baru
  • logout keluar dari akun
  • _mapAuthError mengubah 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:

  • fromFirestore dipakai saat membaca data
  • toCreateJson dipakai saat membuat task
  • toUpdateJson dipakai saat update task
  • copyWith membantu 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:

  • TaskRepository tidak 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:

  • TaskRepository jadi 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:

  • BuildContext
  • SnackBar
  • Navigator
  • 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:

  • AuthRepository
  • TaskRepository
  • ProfileRepository
  • NotificationRepository

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 uid user 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: