firestore_plus
Production-ready Firestore utilities for Flutter — caching, retry, error handling, pagination, logging, transactions, batch operations and more.
firestore_plus acts as an application-level infrastructure wrapper on top of cloud_firestore. It doesn't replace the native Firebase capabilities but enhances them to give you robust, typed, and predictable data access.
Features
- Typed Collections & Documents: Full support for
.fromFirestoreand.toFirestore— no code generation required. - Intelligent Caching: Advanced cache policies (
networkFirst,cacheFirst,staleWhileRevalidate,networkOnly,cacheOnly) with TTL and LRU eviction. - Resilient Retries: Configurable exponential backoff and jitter for transient failures with a custom
retryIfcallback. - Error Normalization: Maps native
FirebaseExceptions into predictableFirestorePlusExceptions. - Request Deduplication: Coalesces identical concurrent read requests to save network calls.
- Transactions & Batching: Safe wrappers that maintain your models' type-safety.
- Observability: Built-in structured logging and metrics listeners.
- Cursor-based Pagination: Type-safe pagination utilities that respect native cursor semantics.
- Timeouts: Per-operation and global timeout enforcement.
- Pluggable Caching: Bring your own persistent cache implementation via the
FirestoreCacheStoreinterface.
Why firestore_plus?
cloud_firestore is a fantastic real-time database, but for a production application, you often need to handle flaky networks, memory caching to prevent excessive reads, timeout enforcement, and predictable error mapping. firestore_plus implements these best practices out-of-the-box so you can focus on building your app.
Installation
Add firestore_plus to your pubspec.yaml:
dependencies:
firestore_plus: ^0.0.1
Then run:
flutter pub get
Quick Start
Initialization
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firestore_plus/firestore_plus.dart';
final firestorePlus = FirestorePlus(
FirebaseFirestore.instance,
config: const FirestorePlusConfig(
defaultCachePolicy: CachePolicy.networkFirst,
defaultTimeout: Duration(seconds: 15),
),
);
Typed Models
class User {
final String id;
final String name;
User({required this.id, required this.name});
factory User.fromFirestore(Map<String, dynamic> data, String id) {
return User(id: id, name: data['name'] as String);
}
Map<String, dynamic> toFirestore() => {'name': name};
}
final users = firestorePlus.collection<User>(
'users',
fromFirestore: User.fromFirestore,
toFirestore: (user) => user.toFirestore(),
);
CRUD
// Add
final newDoc = await users.add(User(id: '', name: 'Alice'));
// Get by ID
final user = await users.getById(newDoc.id);
// Set (with specific ID)
await users.doc('user-1').set(User(id: 'user-1', name: 'Bob'));
// Update
await users.doc(newDoc.id).update({'name': 'Alice Updated'});
// Check existence
final exists = await users.exists(newDoc.id);
// Count
final count = await users.query().count();
// Delete
await users.delete(newDoc.id);
Queries
final results = await users
.query()
.where('age', isGreaterThan: 21)
.orderBy('age')
.limit(10)
.get();
Supported query operations: where, whereIn, whereNotIn, arrayContains, arrayContainsAny, isNull, isEqualTo, isNotEqualTo, isGreaterThan, isGreaterThanOrEqualTo, isLessThan, isLessThanOrEqualTo, orderBy, limit, limitToLast, startAt, startAfter, endAt, endBefore.
Native query escape hatch: Access the underlying Query via query.nativeQuery at any time.
Caching
Override global config per-operation:
final user = await users.getById('someId',
options: const FirestoreOperationOptions(
cachePolicy: CachePolicy.cacheFirst,
cacheDuration: Duration(minutes: 5),
),
);
Cache Policies
| Policy | Behavior |
|---|---|
networkOnly |
Always fetch from network. Updates cache. |
cacheFirst |
Return cached data if available; otherwise fetch from network. |
cacheOnly |
Return cached data only; never fetch from network. |
networkFirst |
Fetch from network; if it fails, fall back to cache. |
staleWhileRevalidate |
Return cached data immediately; refresh in the background. |
TTL
Set a time-to-live on cache entries:
options: const FirestoreOperationOptions(
cacheDuration: Duration(minutes: 10),
),
After the TTL expires, the next cacheFirst request will fetch fresh data from the network.
Cache Invalidation
// Invalidate a specific document
await firestorePlus.cache.invalidate('users/user-1');
// Invalidate an entire collection
await firestorePlus.cache.invalidateCollection('users');
// Clear the entire cache
await firestorePlus.cache.clear();
Writes (set, update, delete) automatically invalidate the cache for the affected document.
Retry
Configure retry behavior globally or per-operation:
const retryPolicy = RetryPolicy(
maxAttempts: 3,
initialDelay: Duration(milliseconds: 500),
maxDelay: Duration(seconds: 5),
backoffMultiplier: 2.0,
jitter: true,
);
final user = await users.getById('id',
options: FirestoreOperationOptions(retryPolicy: retryPolicy),
);
Named constructors for common cases:
const RetryPolicy.exponential(maxAttempts: 3)
const RetryPolicy.none() // Never retry
Custom retry predicate:
RetryPolicy(
maxAttempts: 5,
retryIf: (error) {
if (error is FirestorePlusException) {
return error.type == FirestoreErrorType.timeout;
}
return false;
},
)
Non-retryable errors (never retried by default): permissionDenied, notFound, invalidArgument, alreadyExists, cancelled.
Error Handling
All errors are normalized into FirestorePlusException:
try {
await users.getById('missing');
} on FirestorePlusException catch (e) {
print(e.type); // FirestoreErrorType.notFound
print(e.message); // descriptive message
print(e.code); // original Firebase error code
print(e.operation); // 'GET users/missing'
print(e.path); // 'users/missing'
print(e.originalException); // the original FirebaseException
print(e.stackTrace); // original stack trace
}
Error types: network, unavailable, timeout, permissionDenied, notFound, invalidArgument, alreadyExists, cancelled, resourceExhausted, serialization, unknown.
Logging
Built-in structured logging with configurable levels:
final firestorePlus = FirestorePlus(
FirebaseFirestore.instance,
config: FirestorePlusConfig(
logger: const ConsoleFirestoreLogger(logLevel: FirestoreLogLevel.debug),
),
);
Output format:
[FirestorePlus] [DEBUG] Cache Policy: cacheFirst for users/user-1
[FirestorePlus] [DEBUG] CACHE HIT for users/user-1
[FirestorePlus] [DEBUG] Retry 1/3 for GET users in 250ms due to: ...
Custom logger: Extend FirestorePlusLogger:
final class MyLogger extends FirestorePlusLogger {
@override
FirestoreLogLevel get logLevel => FirestoreLogLevel.info;
@override
void log(FirestoreLogLevel level, String message,
{Object? error, StackTrace? stackTrace}) {
// Send to your logging service
}
}
Document data is never logged by default.
Timeouts
Per-operation:
await users.getById('id',
options: const FirestoreOperationOptions(
timeout: Duration(seconds: 5),
),
);
Global default:
const FirestorePlusConfig(
defaultTimeout: Duration(seconds: 15),
)
Timeout errors throw FirestorePlusException with FirestoreErrorType.timeout.
Pagination
Cursor-based pagination using native Firestore cursor semantics:
final page1 = await users
.query()
.orderBy('createdAt', descending: true)
.paginate(limit: 20);
print('Items: ${page1.items.length}');
print('Has more: ${page1.hasMore}');
// Next page
if (page1.hasMore && page1.cursor != null) {
final page2 = await users
.query()
.orderBy('createdAt', descending: true)
.paginate(limit: 20, startAfter: page1.cursor);
}
PaginatedResult<T> provides: items, hasMore, cursor.
Streams
Typed real-time streams for documents and queries:
// Document stream
final stream = users.doc('user-1').snapshots();
stream.listen((User? user) {
print('Updated: $user');
});
// Query stream
final queryStream = users
.query()
.where('age', isGreaterThan: 21)
.snapshots();
queryStream.listen((List<User> users) {
print('${users.length} users');
});
Serialization errors in streams are caught, logged, and propagated as FirestorePlusException.
Batch Operations
final batch = firestorePlus.batch();
batch.set(users.doc('1'), User(id: '1', name: 'One'));
batch.set(users.doc('2'), User(id: '2', name: 'Two'));
batch.update(users.doc('3'), {'name': 'Three Updated'});
batch.delete(users.doc('4'));
await batch.commit();
Cache is automatically invalidated for all affected documents after a successful commit. Access the native WriteBatch via batch.nativeBatch.
Transactions
await firestorePlus.runTransaction<void>((tx) async {
final doc = users.doc('user-1');
final user = await tx.get(doc);
if (user != null) {
tx.update(doc, {'age': user.age + 1});
}
});
Firestore transaction semantics are preserved. No unsafe retry logic is added on top of native transaction retries. Access the native Transaction via tx.nativeTransaction.
Request Deduplication
When enabled (default), identical concurrent read requests are coalesced into a single Firestore call:
// These three concurrent reads result in ONE Firestore request:
final results = await Future.wait([
users.getById('user-1'),
users.getById('user-1'),
users.getById('user-1'),
]);
Disable via config:
const FirestorePlusConfig(
enableRequestDeduplication: false,
)
Metrics
Track operation stats with a custom metrics listener:
class AppMetricsListener implements FirestoreMetricsListener {
@override
void onOperationComplete(FirestoreOperationMetrics metrics) {
print('${metrics.type}: ${metrics.duration.inMilliseconds}ms');
}
}
final firestorePlus = FirestorePlus(
FirebaseFirestore.instance,
config: FirestorePlusConfig(
metricsListener: AppMetricsListener(),
),
);
FirestoreOperationMetrics provides: type, path, duration, servedFromCache, isSuccess, retryCount, error.
Metrics remain local. Nothing is sent externally.
Custom Cache Store
Implement FirestoreCacheStore for persistent caching (e.g., Hive, SQLite):
class HiveCacheStore implements FirestoreCacheStore {
@override
Future<CacheEntry<Map<String, dynamic>>?> get(String key) async { /* ... */ }
@override
Future<void> put(String key, Map<String, dynamic> value, {Duration? ttl}) async { /* ... */ }
@override
Future<void> remove(String key) async { /* ... */ }
@override
Future<void> clear() async { /* ... */ }
@override
Future<void> clearCollection(String collection) async { /* ... */ }
}
final firestorePlus = FirestorePlus(
FirebaseFirestore.instance,
config: FirestorePlusConfig(
cacheStore: HiveCacheStore(),
),
);
The built-in MemoryCacheStore supports configurable maxSize (default 500 entries) with LRU eviction.
Testing
The package is designed for testability. Use fake_cloud_firestore for unit tests:
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:firestore_plus/firestore_plus.dart';
void main() {
test('example test', () async {
final fakeFirestore = FakeFirebaseFirestore();
final firestorePlus = FirestorePlus(
fakeFirestore,
config: const FirestorePlusConfig(
defaultCachePolicy: CachePolicy.networkOnly,
defaultRetryPolicy: RetryPolicy.none(),
),
);
final users = firestorePlus.collection<User>(
'users',
fromFirestore: User.fromFirestore,
toFirestore: (user) => user.toFirestore(),
);
await users.doc('1').set(User(id: '1', name: 'Test'));
final user = await users.getById('1');
expect(user!.name, 'Test');
});
}
Offline Firestore vs Application Cache
cloud_firestore already offers local offline persistence. firestore_plus provides an Application Cache which sits on top of Firestore and helps prevent redundant network reads within your app's lifecycle, reducing billable reads and improving UI responsiveness without relying solely on Firestore's native cache engine.
| Native Firestore Offline | firestore_plus Cache | |
|---|---|---|
| Scope | SDK-level persistence | Application-level read caching |
| Purpose | Offline access & sync | Reduce reads & improve responsiveness |
| Configured by | FirebaseFirestore settings | FirestorePlusConfig |
| Policies | Automatic | networkFirst, cacheFirst, etc. |
| TTL | No | Yes |
| Eviction | SDK-managed | Configurable (maxSize, TTL) |
Performance & Best Practices
- Use
CachePolicy.cacheFirstfor data that rarely changes to minimize reads. - Use
CachePolicy.staleWhileRevalidatefor data that should feel instant but stay fresh. - Set reasonable TTLs to avoid serving stale data indefinitely.
- Configure
MemoryCacheStore(maxSize:)based on your app's memory budget. - Enable request deduplication (default) to avoid redundant reads from concurrent widgets.
- Use
RetryPolicy.none()in tests for faster execution. - Avoid caching paginated results — use
CachePolicy.networkOnlyfor pagination.
Migration from cloud_firestore
firestore_plus is additive. You don't need to migrate everything at once:
- Add
firestore_plusas a dependency. - Create a
FirestorePlusinstance wrapping your existingFirebaseFirestore.instance. - Gradually convert collections to use
firestore.collection<T>(...). - Your existing
cloud_firestorecode continues to work alongsidefirestore_plus. - Access native references at any time via
nativeRef,nativeQuery,nativeBatch,nativeTransaction.
FAQ
Does this replace cloud_firestore?
No. firestore_plus is built on top of cloud_firestore and requires it.
Does this handle Firebase authentication? No. This package is focused exclusively on Firestore data access.
Does this send any analytics or telemetry? No. Metrics remain local unless you explicitly connect a listener.
Can I use this with Riverpod / Bloc / Provider?
Yes. firestore_plus is state-management agnostic. It provides data access utilities that work with any architecture.
Does this provide Firestore security rules? No. Security rules are the responsibility of your Firebase project.
Limitations
- Subcollections are not directly modeled — use
firestore.collection<T>('parent/docId/subcollection', ...). staleWhileRevalidatebackground fetch errors are logged but not propagated to the caller.- Pagination cursor caching requires careful policy selection (prefer
networkOnlyornetworkFirst). fake_cloud_firestorehas limited support forstartAfterDocumentpagination in tests.
Roadmap
Aggregate query support (sum, average)Subcollection helper APIOffline queue for pending writesBuilt-in rate limitingCache statistics dashboardHive/SQLite cache store packages
Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Write tests for new functionality
- Ensure
dart analyzeanddart formatpass - Submit a pull request
License
MIT — see LICENSE for details.