Cloud Firestore — Data Modelling & CRUD Operations
Cloud Firestore — Data Modelling & CRUD Operations
Cloud Firestore is a flexible, scalable NoSQL document database built for mobile and web applications. Unlike relational databases, Firestore organises data into collections and documents. Understanding this structure — and its constraints — is the key to designing a schema that performs well and scales gracefully.
Collections, Documents & Sub-Collections
Firestore stores data in a hierarchy: a collection holds one or more documents, and each document can contain nested sub-collections. A document is a JSON-like object with named fields. Every document has an auto-generated (or user-defined) string ID and lives at a path like users/uid123.
- Collection — a container of documents (e.g.,
users,posts) - Document — a single record with typed fields (String, Number, Boolean, Timestamp, Array, Map, Reference)
- Sub-collection — a collection nested inside a document (e.g.,
users/uid123/orders)
Practical Data Model — A Notes App
For this lesson we will model a simple notes application. Each authenticated user owns a set of notes. The schema looks like this:
Firestore Schema (conceptual)
// Top-level collection: notes
// Document ID: auto-generated
// Path: notes/{noteId}
{
"title": "Meeting agenda",
"body": "Discuss Q3 goals...",
"authorId": "uid_abc123",
"tags": ["work", "q3"],
"createdAt": Timestamp,
"updatedAt": Timestamp
}
Adding the cloud_firestore Package
Add the dependency in pubspec.yaml and run flutter pub get:
pubspec.yaml
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
cloud_firestore: ^5.0.0
Make sure Firebase.initializeApp() is called in main() before accessing Firestore (covered in Lesson 1).
CRUD Operations with cloud_firestore
All four fundamental operations — Create, Read, Update, Delete — use the FirebaseFirestore.instance singleton. The API is asynchronous; every write returns a Future and every read returns either a Future (one-time fetch) or a Stream (real-time listener).
Full CRUD Service Class
import 'package:cloud_firestore/cloud_firestore.dart';
class NoteService {
final FirebaseFirestore _db = FirebaseFirestore.instance;
final String _col = 'notes';
// CREATE — add a new document with an auto-generated ID
Future<DocumentReference> createNote({
required String title,
required String body,
required String authorId,
List<String> tags = const [],
}) async {
final data = {
'title': title,
'body': body,
'authorId': authorId,
'tags': tags,
'createdAt': FieldValue.serverTimestamp(),
'updatedAt': FieldValue.serverTimestamp(),
};
return _db.collection(_col).add(data);
}
// READ — fetch a single document by ID
Future<Map<String, dynamic>?> getNoteById(String noteId) async {
final snap = await _db.collection(_col).doc(noteId).get();
if (!snap.exists) return null;
return {'id': snap.id, ...?snap.data()};
}
// READ — real-time stream of all notes for a user
Stream<QuerySnapshot> watchUserNotes(String authorId) {
return _db
.collection(_col)
.where('authorId', isEqualTo: authorId)
.orderBy('updatedAt', descending: true)
.snapshots();
}
// UPDATE — merge specific fields (does NOT overwrite entire document)
Future<void> updateNote(
String noteId, {
String? title,
String? body,
List<String>? tags,
}) async {
final patch = <String, dynamic>{
'updatedAt': FieldValue.serverTimestamp(),
if (title != null) 'title': title,
if (body != null) 'body': body,
if (tags != null) 'tags': tags,
};
await _db.collection(_col).doc(noteId).update(patch);
}
// DELETE — permanently remove a document
Future<void> deleteNote(String noteId) async {
await _db.collection(_col).doc(noteId).delete();
}
}
update() over set() when you only want to change a few fields. set() without SetOptions(merge: true) will overwrite the entire document, silently deleting fields you did not include.Error Handling
Firestore operations can fail due to network issues, permission denials (Security Rules), or invalid queries. Always wrap calls in try/catch and handle FirebaseException:
Error Handling Pattern
Future<void> safeDelete(String noteId) async {
try {
await _db.collection('notes').doc(noteId).delete();
} on FirebaseException catch (e) {
// e.code is a string like 'permission-denied', 'not-found'
debugPrint('Firestore error [${e.code}]: ${e.message}');
rethrow; // let the UI layer decide how to display the error
}
}
FirebaseException.code values such as permission-denied or unavailable to friendly, localised messages in the UI layer.Summary
In this lesson you learned how Firestore's collection/document hierarchy works, how to design a practical schema, and how to implement all four CRUD operations using the cloud_firestore package. Key takeaways:
- Use collections for lists of similar entities; use documents for individual records.
add()generates an ID automatically;doc(id).set()lets you supply your own.update()merges changes;set()without merge replaces the whole document.FieldValue.serverTimestamp()is the correct way to record creation/update times.- Always handle
FirebaseExceptionand map error codes to user-friendly messages.