Firestore CRUD & Offline-First Data Layer
Firestore CRUD & Offline-First Data Layer
Cloud Firestore is a NoSQL document database that gives Flutter apps real-time data synchronization, seamless offline persistence, and a flexible schema. In a clean-architecture Flutter project, all Firestore interactions belong in the data layer — specifically inside a repository implementation that satisfies a domain-layer interface. This lesson walks you through building that repository end-to-end: enabling offline persistence, implementing create/read/update/delete, and exposing reactive streams that your presentation layer can consume without knowing anything about Firestore.
Setting Up the Firestore Data Source
Add the dependency and initialize Firestore with offline persistence enabled before runApp. Offline persistence caches documents locally so reads succeed even when the device has no network, and queued writes are flushed automatically when connectivity is restored.
main.dart — Initialize Firestore with Persistence
import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// Enable unlimited offline persistence (default on mobile is 40 MB)
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
runApp(const MyApp());
}
FirebaseFirestore.instance.enablePersistence() separately. On Android and iOS, persistenceEnabled: true in Settings is sufficient.Defining the Domain Interface
Your domain layer declares an abstract repository interface. The data layer provides the concrete Firestore implementation. This inversion keeps business logic testable and swap-able — you can replace Firestore with a local SQLite backend simply by swapping the registered implementation.
domain/repositories/task_repository.dart
import '../entities/task.dart';
abstract class TaskRepository {
/// Returns a real-time stream of all tasks ordered by creation time.
Stream<List<Task>> watchAll();
/// Creates a new task and returns the auto-generated document ID.
Future<String> create(Task task);
/// Updates an existing task identified by [task.id].
Future<void> update(Task task);
/// Permanently deletes the task with the given [id].
Future<void> delete(String id);
}
Implementing CRUD Operations in the Repository
The Firestore repository implementation converts raw DocumentSnapshot objects into typed domain entities and back. Each write operation (create, update, delete) returns a Future that resolves when Firestore acknowledges the write — or immediately if the device is offline (the write is queued locally and synced later). The watchAll stream emits a new list every time the underlying collection changes, whether from the local cache or the server.
data/repositories/firestore_task_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';
class FirestoreTaskRepository implements TaskRepository {
final FirebaseFirestore _db;
static const _collection = 'tasks';
FirestoreTaskRepository({FirebaseFirestore? db})
: _db = db ?? FirebaseFirestore.instance;
CollectionReference<Map<String, dynamic>> get _col =>
_db.collection(_collection);
// ── CREATE ────────────────────────────────────────────────────────────────
@override
Future<String> create(Task task) async {
final doc = await _col.add(task.toMap());
return doc.id;
}
// ── READ (real-time stream) ────────────────────────────────────────────────
@override
Stream<List<Task>> watchAll() {
return _col
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) => snapshot.docs
.map((doc) => Task.fromMap(doc.id, doc.data()))
.toList());
}
// ── UPDATE ────────────────────────────────────────────────────────────────
@override
Future<void> update(Task task) async {
await _col.doc(task.id).update(task.toMap());
}
// ── DELETE ────────────────────────────────────────────────────────────────
@override
Future<void> delete(String id) async {
await _col.doc(id).delete();
}
}
.snapshots() over .get() for data that your UI displays. snapshots() returns a Stream that emits a fresh list whenever the data changes, giving you real-time updates with zero extra code. Use .get() only for one-shot reads where live updates are unnecessary (e.g., populating a print preview).The Task Entity and Data Mapping
Domain entities are plain Dart classes — they must not import Firebase packages. Serialization logic lives in the data layer via fromMap factory constructors and toMap methods. Using FieldValue.serverTimestamp() for createdAt ensures the timestamp is set by Firestore's servers, not the client clock, which prevents skew across devices.
domain/entities/task.dart (entity) and data/models/task_model.dart (mapping)
// ── Domain entity — no Firebase imports ───────────────────────────────────
class Task {
final String id;
final String title;
final bool isDone;
final DateTime? createdAt;
const Task({
required this.id,
required this.title,
this.isDone = false,
this.createdAt,
});
Task copyWith({String? title, bool? isDone}) => Task(
id: id,
title: title ?? this.title,
isDone: isDone ?? this.isDone,
createdAt: createdAt,
);
// ── Firestore serialization helpers ──────────────────────────────────────
factory Task.fromMap(String id, Map<String, dynamic> map) => Task(
id: id,
title: map['title'] as String,
isDone: map['isDone'] as bool? ?? false,
createdAt: (map['createdAt'] as Timestamp?)?.toDate(),
);
Map<String, dynamic> toMap() => {
'title': title,
'isDone': isDone,
'createdAt': FieldValue.serverTimestamp(),
};
}
Timestamp or any Firestore type inside your domain entity. Convert Firestore types (Timestamp, GeoPoint, DocumentReference) to plain Dart types (DateTime, LatLng, String) in fromMap. This keeps your domain layer decoupled from the Firebase SDK and makes unit testing trivial.Connecting the Stream to the Presentation Layer
With Riverpod (or any reactive state management solution), you can pipe the repository stream directly into the UI. A StreamProvider automatically rebuilds the widget tree whenever a new list of tasks arrives from Firestore — whether from the local offline cache or the remote server — without any manual setState calls.
Riverpod StreamProvider wiring
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';
import '../repositories/firestore_task_repository.dart';
// ── DI: expose the repository implementation ─────────────────────────────
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
return FirestoreTaskRepository();
});
// ── Stream of tasks — auto-updated in real time ───────────────────────────
final tasksProvider = StreamProvider<List<Task>>((ref) {
return ref.watch(taskRepositoryProvider).watchAll();
});
// ── In a widget ───────────────────────────────────────────────────────────
class TaskListScreen extends ConsumerWidget {
const TaskListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksProvider);
return tasksAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (tasks) => ListView.builder(
itemCount: tasks.length,
itemBuilder: (_, i) => ListTile(
title: Text(tasks[i].title),
trailing: Icon(
tasks[i].isDone ? Icons.check_circle : Icons.circle_outlined,
),
),
),
);
}
}
Summary
You now have a fully offline-first Firestore data layer that follows clean-architecture principles. Key takeaways from this lesson:
- Enable offline persistence in
SettingsbeforerunAppso the app works without a connection. - Define the repository contract in the domain layer and implement it in the data layer.
- Use
.add()for create,.update()for partial updates,.delete()for removal, and.snapshots()for real-time reads. - Keep domain entities free of Firestore types; serialize/deserialize in
fromMap/toMap. - Pipe the repository stream into a StreamProvider so the UI rebuilds reactively with zero boilerplate.