Firebase Integration

Cloud Firestore — Data Modelling & CRUD Operations

16 min Lesson 5 of 13

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)
Note: Documents are limited to 1 MB in size. Avoid storing large blobs or unbounded arrays inside a single document. Use sub-collections or separate top-level collections when a relationship could grow without limit.

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();
  }
}
Tip: Prefer 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
  }
}
Warning: Never expose raw Firestore errors directly to users. Map 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 FirebaseException and map error codes to user-friendly messages.