Firebase Auth e Analytics sono spesso i primi servizi che integriamo in un'app Flutter, ma il cuore della maggior parte dei progetti è il database. Cloud Firestore è un database documentale NoSQL, realtime e con supporto offline nativo: si sposa benissimo con il modello reattivo di Flutter, ma va usato con criterio per evitare query costose, modelli fragili e — soprattutto — dati esposti a chiunque.

In questa guida vediamo come strutturare un layer dati Firestore solido: modelli type-safe, query efficienti, streaming, paginazione, scritture atomiche e regole di sicurezza.

Setup

Dopo aver configurato il progetto con FlutterFire CLI (dart pub global activate flutterfire_cli e flutterfire configure), aggiungi le dipendenze:

dependencies:
  firebase_core: ^3.8.0
  cloud_firestore: ^5.5.0

Inizializzazione classica in main:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
    cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
  );

  runApp(const MyApp());
}

Su mobile la persistenza offline è attiva di default; su Web va abilitata esplicitamente (e funziona in una sola tab alla volta, salvo usare la persistenza multi-tab).

Modellare i dati: il modello documentale

Il primo errore tipico di chi arriva da SQL è normalizzare tutto. In Firestore si paga per documento letto, non per byte: conviene quindi denormalizzare i dati che servono in lista (titolo, autore, thumbnail) ed evitare di dover fare N letture per riempire una schermata.

Regole pratiche:

  • una collection per ogni entità principale (posts, users, orders);
  • usa le subcollection per relazioni uno-a-molti illimitate (posts/{id}/comments);
  • duplica i campi che ti servono per filtrare o ordinare;
  • un documento non può superare 1 MB: niente array che crescono all'infinito.

Modelli type-safe con withConverter

Lavorare con Map<String, dynamic> sparsi nei widget è la via più rapida al crash in produzione. withConverter permette di ottenere collection e documenti già tipizzati.

class Post {
  const Post({
    required this.id,
    required this.title,
    required this.authorId,
    required this.tags,
    required this.createdAt,
    this.likes = 0,
  });

  final String id;
  final String title;
  final String authorId;
  final List<String> tags;
  final DateTime createdAt;
  final int likes;

  factory Post.fromFirestore(
    DocumentSnapshot<Map<String, dynamic>> doc,
  ) {
    final data = doc.data()!;
    return Post(
      id: doc.id,
      title: data['title'] as String,
      authorId: data['authorId'] as String,
      tags: List<String>.from(data['tags'] as List? ?? const []),
      createdAt: (data['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(),
      likes: (data['likes'] as num?)?.toInt() ?? 0,
    );
  }

  Map<String, dynamic> toFirestore() => {
        'title': title,
        'authorId': authorId,
        'tags': tags,
        'createdAt': FieldValue.serverTimestamp(),
        'likes': likes,
      };
}

Nota FieldValue.serverTimestamp(): il timestamp viene generato dal server, non dall'orologio del dispositivo (che l'utente può manipolare). Attenzione però: nello snapshot locale ottimistico il campo è null finché il server non conferma — per questo nel factory c'è un fallback.

Ora la reference tipizzata:

final postsRef = FirebaseFirestore.instance
    .collection('posts')
    .withConverter<Post>(
      fromFirestore: (snapshot, _) => Post.fromFirestore(snapshot),
      toFirestore: (post, _) => post.toFirestore(),
    );

// Lettura già tipizzata
final snapshot = await postsRef.doc('abc123').get();
final Post? post = snapshot.data();

Query: filtri, ordinamenti e indici

Firestore supporta filtri combinati, ma con vincoli precisi. Un esempio tipico:

Query<Post> recentByTag(String tag) => postsRef
    .where('tags', arrayContains: tag)
    .where('published', isEqualTo: true)
    .orderBy('createdAt', descending: true)
    .limit(20);

Punti da ricordare:

  • una query con filtri di uguaglianza su più campi più un orderBy diverso richiede un indice composito. Alla prima esecuzione la console stampa un link diretto per crearlo: usalo e poi versiona gli indici in firestore.indexes.json;
  • puoi avere un solo campo con filtri di range (<, >, <=, >=) e l'orderBy deve partire da quel campo;
  • arrayContainsAny, whereIn e whereNotIn accettano al massimo 30 valori;
  • dalla versione recente di cloud_firestore esistono anche i filtri OR tramite Filter:
final query = postsRef.where(
  Filter.or(
    Filter('authorId', isEqualTo: currentUserId),
    Filter('visibility', isEqualTo: 'public'),
  ),
);

Firestore non ha ricerca full-text: per quella servono servizi esterni (Algolia, Typesense, Meilisearch) alimentati da Cloud Functions.

Realtime con gli stream

Il valore aggiunto di Firestore è lo streaming dei cambiamenti. Il pattern più semplice lato UI:

StreamBuilder<QuerySnapshot<Post>>(
  stream: recentByTag('flutter').snapshots(),
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return const Center(child: Text('Errore di caricamento'));
    }
    if (!snapshot.hasData) {
      return const Center(child: CircularProgressIndicator());
    }

    final posts = snapshot.data!.docs.map((d) => d.data()).toList();
    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (context, i) => PostTile(post: posts[i]),
    );
  },
)

Due accorgimenti importanti:

  1. Non creare lo stream dentro il build: ogni rebuild aprirebbe un nuovo listener. Memorizzalo in un campo dello State, in un provider Riverpod o in un repository.
  2. Usa snapshots(includeMetadataChanges: true) se vuoi distinguere i dati locali (scritture ottimistiche) da quelli confermati dal server, leggendo snapshot.metadata.hasPendingWrites e isFromCache.

Un repository ben fatto espone stream di modelli, non di snapshot:

class PostRepository {
  PostRepository(this._db);
  final FirebaseFirestore _db;

  Stream<List<Post>> watchByTag(String tag) => _db
      .collection('posts')
      .withConverter<Post>(
        fromFirestore: (s, _) => Post.fromFirestore(s),
        toFirestore: (p, _) => p.toFirestore(),
      )
      .where('tags', arrayContains: tag)
      .orderBy('createdAt', descending: true)
      .limit(50)
      .snapshots()
      .map((s) => s.docs.map((d) => d.data()).toList());
}

Paginazione con cursori

Dimentica offset: Firestore usa i cursori basati sull'ultimo documento caricato, molto più efficienti.

class PostPager {
  PostPager(this._query);
  final Query<Post> _query;
  DocumentSnapshot<Post>? _last;
  bool hasMore = true;

  Future<List<Post>> loadNext({int pageSize = 20}) async {
    if (!hasMore) return const [];

    var q = _query.limit(pageSize);
    if (_last != null) q = q.startAfterDocument(_last!);

    final snap = await q.get();
    if (snap.docs.length < pageSize) hasMore = false;
    if (snap.docs.isNotEmpty) _last = snap.docs.last;

    return snap.docs.map((d) => d.data()).toList();
  }
}

Questo pager si integra perfettamente con infinite_scroll_pagination o con un semplice ScrollController.

Scritture: atomicità con transazioni e batch

Per incrementare un contatore non leggere-e-riscrivere: usa gli operatori atomici lato server.

await postsRef.doc(postId).update({
  'likes': FieldValue.increment(1),
  'likedBy': FieldValue.arrayUnion([userId]),
});

Quando devi leggere e scrivere in modo consistente (es. scalare uno stock solo se disponibile), serve una transazione, che viene ritentata automaticamente in caso di conflitto:

Future<void> buy(String productId, int qty) {
  final db = FirebaseFirestore.instance;
  final ref = db.collection('products').doc(productId);

  return db.runTransaction((tx) async {
    final snap = await tx.get(ref);
    final stock = (snap.data()?['stock'] as num?)?.toInt() ?? 0;
    if (stock < qty) {
      throw Exception('Stock insufficiente');
    }
    tx.update(ref, {'stock': stock - qty});
    tx.set(db.collection('orders').doc(), {
      'productId': productId,
      'qty': qty,
      'createdAt': FieldValue.serverTimestamp(),
    });
  });
}

Attenzione: nelle transazioni tutte le letture vanno fatte prima delle scritture e le transazioni non funzionano offline (falliscono se il device non è connesso).

Per scritture multiple indipendenti (fino a 500 operazioni) usa un WriteBatch, che è atomico e funziona anche offline:

final batch = FirebaseFirestore.instance.batch();
for (final id in idsDaArchiviare) {
  batch.update(postsRef.doc(id), {'archived': true});
}
await batch.commit();

Offline-first quasi gratis

Con la persistenza attiva, le scritture vengono messe in coda localmente e gli stream emettono subito i dati dalla cache: l'app resta usabile senza rete. Puoi anche forzare la sorgente:

final cached = await postsRef.doc(id).get(const GetOptions(source: Source.cache));

Non attendere await sulle scritture se vuoi UI ottimistica istantanea: la Future si completa solo quando il server conferma, mentre lo stream locale è già aggiornato. Gestisci comunque l'errore con un catchError per non avere eccezioni non catturate.

Regole di sicurezza: il passaggio che non si può saltare

Un'app Flutter parla direttamente con Firestore: senza regole, chiunque estragga le chiavi dal bundle può leggere e cancellare tutto. Le regole vanno scritte insieme al modello dati.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {

    function isSignedIn() {
      return request.auth != null;
    }

    match /posts/{postId} {
      allow read: if resource.data.published == true
                  || (isSignedIn() && resource.data.authorId == request.auth.uid);

      allow create: if isSignedIn()
                    && request.resource.data.authorId == request.auth.uid
                    && request.resource.data.title is string
                    && request.resource.data.title.size() <= 120;

      allow update, delete: if isSignedIn()
                            && resource.data.authorId == request.auth.uid;

      match /comments/{commentId} {
        allow read: if true;
        allow create: if isSignedIn();
      }
    }
  }
}

Ricorda che le regole non filtrano i dati: se una query può restituire documenti non consentiti, la query fallisce interamente. Le query dell'app devono quindi "rispecchiare" le regole (es. filtrare esplicitamente su published == true).

Per sviluppare in sicurezza usa l'emulatore:

if (kDebugMode) {
  FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080);
}

e testa le regole con firebase emulators:exec --only firestore e la suite @firebase/rules-unit-testing.

Testare il layer dati

Per i test di unità del repository esiste fake_cloud_firestore, che simula in memoria l'API:

test('watchByTag restituisce solo i post del tag', () async {
  final firestore = FakeFirebaseFirestore();
  await firestore.collection('posts').add({
    'title': 'Ciao',
    'authorId': 'u1',
    'tags': ['flutter'],
    'createdAt': Timestamp.now(),
  });

  final repo = PostRepository(firestore);
  final posts = await repo.watchByTag('flutter').first;

  expect(posts, hasLength(1));
  expect(posts.first.title, 'Ciao');
});

Controllare i costi

Firestore fattura letture, scritture e cancellazioni. Alcune abitudini che fanno la differenza:

  • sempre limit() sulle query in lista, mai get() su collection intere;
  • evita listener duplicati: uno stream condiviso in un repository (o un StreamProvider di Riverpod con autoDispose) invece di uno per widget;
  • mantieni contatori aggregati (commentsCount) aggiornati con FieldValue.increment invece di contare documenti;
  • se ti serve solo il numero, usa le aggregation query: await query.count().get() costa molto meno di leggere i documenti;
  • chiudi le subscription nel dispose() per non tenere aperti listener invisibili.

Conclusioni

Firestore permette di costruire in poche ore un backend reattivo e offline-first per un'app Flutter, ma la qualità del risultato dipende da tre scelte fatte all'inizio: un modello dati denormalizzato pensato per le query reali, un layer repository con modelli tipizzati tramite withConverter, e regole di sicurezza scritte e testate con l'emulatore. Con queste fondamenta, stream, paginazione e scritture atomiche diventano solo dettagli implementativi — e l'app resta veloce e prevedibile anche quando gli utenti crescono.