firestore_plus 0.0.1 copy "firestore_plus: ^0.0.1" to clipboard
firestore_plus: ^0.0.1 copied to clipboard

Production-ready Firestore utilities for Flutter — caching, retry, error handling, pagination, logging, transactions, batch operations and more.

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 .fromFirestore and .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 retryIf callback.
  • Error Normalization: Maps native FirebaseExceptions into predictable FirestorePlusExceptions.
  • 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 FirestoreCacheStore interface.

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.cacheFirst for data that rarely changes to minimize reads.
  • Use CachePolicy.staleWhileRevalidate for 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.networkOnly for pagination.

Migration from cloud_firestore #

firestore_plus is additive. You don't need to migrate everything at once:

  1. Add firestore_plus as a dependency.
  2. Create a FirestorePlus instance wrapping your existing FirebaseFirestore.instance.
  3. Gradually convert collections to use firestore.collection<T>(...).
  4. Your existing cloud_firestore code continues to work alongside firestore_plus.
  5. 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', ...).
  • staleWhileRevalidate background fetch errors are logged but not propagated to the caller.
  • Pagination cursor caching requires careful policy selection (prefer networkOnly or networkFirst).
  • fake_cloud_firestore has limited support for startAfterDocument pagination in tests.

Roadmap #

  • ❌ Aggregate query support (sum, average)
  • ❌ Subcollection helper API
  • ❌ Offline queue for pending writes
  • ❌ Built-in rate limiting
  • ❌ Cache statistics dashboard
  • ❌ Hive/SQLite cache store packages

Contributing #

Contributions are welcome! Please:

  1. Fork the repository
  2. Create a feature branch
  3. Write tests for new functionality
  4. Ensure dart analyze and dart format pass
  5. Submit a pull request

License #

MIT — see LICENSE for details.

0
likes
160
points
106
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Production-ready Firestore utilities for Flutter — caching, retry, error handling, pagination, logging, transactions, batch operations and more.

Repository (GitHub)
View/report issues

Topics

#firebase #firestore #flutter #database #cache

License

MIT (license)

Dependencies

cloud_firestore, firebase_core, flutter, meta

More

Packages that depend on firestore_plus