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
orderBydiverso richiede un indice composito. Alla prima esecuzione la console stampa un link diretto per crearlo: usalo e poi versiona gli indici infirestore.indexes.json; - puoi avere un solo campo con filtri di range (
<,>,<=,>=) e l'orderBydeve partire da quel campo; arrayContainsAny,whereInewhereNotInaccettano al massimo 30 valori;- dalla versione recente di
cloud_firestoreesistono anche i filtriORtramiteFilter:
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:
- Non creare lo stream dentro il
build: ogni rebuild aprirebbe un nuovo listener. Memorizzalo in un campo delloState, in un provider Riverpod o in un repository. - Usa
snapshots(includeMetadataChanges: true)se vuoi distinguere i dati locali (scritture ottimistiche) da quelli confermati dal server, leggendosnapshot.metadata.hasPendingWriteseisFromCache.
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, maiget()su collection intere; - evita listener duplicati: uno stream condiviso in un repository (o un
StreamProviderdi Riverpod conautoDispose) invece di uno per widget; - mantieni contatori aggregati (
commentsCount) aggiornati conFieldValue.incrementinvece 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.