Best Practices
Patterns and conventions for building maintainable, efficient applications with FlintFire.
These recommendations cover repository lifecycle, query efficiency, data hygiene, and hook design. Each one includes a working example and the reasoning behind it.
1. Initialize repositories once
Section titled “1. Initialize repositories once”Create repository instances once and reuse them throughout your application. Don’t create new instances inside every function.
// ❌ Bad - Creates a new instance every timeexport function getUserRepository() { return FirestoreRepository.withSchema(db, 'users', userSchema);}
// ✅ Good - Single instance, reused everywhereexport const userRepo = FirestoreRepository.withSchema(db, 'users', userSchema);Why: Repository construction is lightweight, but recreating instances repeatedly is unnecessary
and makes hook management inconsistent — hooks registered with on() live on the instance, so a
fresh instance loses every listener you attached to the previous one.
2. Organize repositories in a centralized module
Section titled “2. Organize repositories in a centralized module”Keep all repository instances and their shared hooks in one module so wiring stays discoverable and each collection has a single source of truth.
import { db } from '../config/firebase';import { FirestoreRepository } from 'flintfire';import * as schemas from '../schemas';
export const userRepo = FirestoreRepository.withSchema(db, 'users', schemas.userSchema);
export const orderRepo = FirestoreRepository.withSchema(db, 'orders', schemas.orderSchema);
export const productRepo = FirestoreRepository.withSchema(db, 'products', schemas.productSchema);
// Setup common hooksuserRepo.on('afterCreate', async user => { await auditLog.record('user_created', user);});
orderRepo.on('afterCreate', async order => { await notificationService.sendOrderConfirmation(order);});3. Use cursor-based pagination over offset
Section titled “3. Use cursor-based pagination over offset”For large datasets, cursor-based pagination is significantly more efficient than offset pagination.
paginate(pageSize, cursor?) requires a prior orderBy() and returns
{ items, nextCursor, hasMore }; pass the previous page’s nextCursor back in to advance.
// ✅ Good - Cursor-based (scales well)const { items, nextCursor, hasMore } = await userRepo .query() .orderBy('createdAt', 'desc') .paginate(20, lastCursor);
// ❌ Avoid - Offset-based (expensive for large page numbers)const result = await userRepo.query().orderBy('createdAt', 'desc').offsetPaginate(100, 20); // Skip 1980 docs to reach page 100Why: Offset pagination requires Firestore to scan and skip every document before your offset, while cursor pagination jumps directly to the starting position. See Queries for the full pagination API.
4. Use query updates for bulk operations
Section titled “4. Use query updates for bulk operations”When updating multiple documents based on a condition, use query().update() instead of fetching
and then updating.
// ✅ Good - Single query, batched writesawait orderRepo .query() .where('status', '==', 'pending') .where('createdAt', '<', cutoffDate) .update({ status: 'expired' });
// ❌ Less efficient - Two operationsconst orders = await orderRepo .query() .where('status', '==', 'pending') .where('createdAt', '<', cutoffDate) .get();
await orderRepo.bulkUpdate(orders.map(o => ({ id: o.id, data: { status: 'expired' } })));Careful with composite filters here. query().update() / query().delete() write exactly the
set the query matches, and an inequality inside a whereFilter(f => f.or(...)) branch excludes
documents missing that field — so a bulk write can silently skip documents you meant to touch while
reporting a successful count. See
Composite AND/OR filters.
Note: query().update() and query().delete() run the bulk lifecycle hooks
(beforeBulkUpdate/afterBulkUpdate, beforeBulkDelete/afterBulkDelete), not the per-document
before/afterUpdate / before/afterDelete hooks. If you rely on per-document
beforeUpdate/afterUpdate or beforeDelete/afterDelete side effects, use the single-document
methods instead. See
Lifecycle hooks.
5. Add timestamps consistently
Section titled “5. Add timestamps consistently”Always add createdAt and updatedAt timestamps to track the data lifecycle. Note that the schema
does not declare a top-level id — the repository sources doc.id from the document name.
const userSchema = z.object({ name: z.string(), email: z.email(), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(),});
// On createawait userRepo.create({ ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),});
// On updateawait userRepo.update(id, { ...data, updatedAt: new Date().toISOString(),});If you prefer to store native Firestore timestamps instead of ISO strings, see Timestamps for the millisecond converter and the hook-based write conversion pattern.
6. Handle composite index errors gracefully
Section titled “6. Handle composite index errors gracefully”Firestore requires composite indexes for certain query combinations. The ORM surfaces a
FirestoreIndexError with a clear message and a link to create the missing index. Because Firestore
normalizes a composite filter and evaluates each disjunct, a whereFilter(f => f.or(…)) query can
require coverage for more than one branch — so one query may surface several successive
FirestoreIndexErrors.
try { const results = await orderRepo .query() .where('status', '==', 'pending') .where('total', '>', 100) .orderBy('createdAt', 'desc') .get();} catch (error) { if (error instanceof FirestoreIndexError) { console.log(error.toString()); // Logs a formatted message with a link to create the index. // Click the link, wait 1-2 minutes for the index to build, then retry the query. }}See Error handling for the full error taxonomy and Troubleshooting for index-related tips.
7. Use transactions for critical operations
Section titled “7. Use transactions for critical operations”Any operation requiring consistency across multiple documents should use a transaction.
runInTransaction passes a transaction-scoped repository; do all reads with getInTransaction
before any writes. For a lock-free consistent snapshot or a PITR / time-travel read, use
{ readOnly: true } or runReadOnlyAt(readTime, fn) — see
Transactions.
// ✅ Atomic transferawait accountRepo.runInTransaction(async (tx, repo) => { const from = await repo.getInTransaction(tx, fromId); const to = await repo.getInTransaction(tx, toId);
// getInTransaction returns FirestoreDocument<Account> | null — guard before use. if (!from || !to) { throw new Error('Account not found'); }
if (from.balance < amount) { throw new Error('Insufficient funds'); }
await repo.updateInTransaction(tx, fromId, { balance: from.balance - amount, });
await repo.updateInTransaction(tx, toId, { balance: to.balance + amount, });});See Transactions for the complete transaction-scoped API.
8. Use streaming for large data exports
Section titled “8. Use streaming for large data exports”When processing large datasets (exports, migrations, batch jobs), use query().stream() to iterate
lazily and avoid loading everything into memory.
// ✅ Memory efficientconst csvStream = createWriteStream('users.csv');csvStream.write('name,email,status\n');
for await (const user of userRepo.query().stream()) { csvStream.write(`${user.name},${user.email},${user.status}\n`);}
csvStream.end();9. Structure hooks for reusability
Section titled “9. Structure hooks for reusability”Keep hooks focused and modular. Avoid putting complex business logic directly inside a hook — delegate to a dedicated service so the logic stays testable.
// ✅ Good - Focused, testableclass UserNotificationService { async sendWelcomeEmail(user: User) { // Email logic here }}
const notificationService = new UserNotificationService();
userRepo.on('afterCreate', async user => { await notificationService.sendWelcomeEmail(user);});
// ❌ Bad - Business logic coupled to the hookuserRepo.on('afterCreate', async user => { const template = await db.collection('templates').doc('welcome').get(); const emailService = new EmailService(config); await emailService.send({ to: user.email, subject: template.data().subject, body: template.data().body.replace('{{name}}', user.name), }); await db.collection('email_logs').add({ userId: user.id, type: 'welcome' });});See Lifecycle hooks for the full event list and payload shapes.