Advanced Patterns
Production-tested recipes that compose firestore-orm’s hooks, transactions, and repository extension points into larger architectural patterns.
Most of these recipes lean on two building blocks: lifecycle hooks to react to
writes, and transactions to keep connected writes atomic. Where a recipe uses the
withSchema factory, remember that the schema must include a required top-level
id: z.string() or the factory throws at construction — see
schema validation for details.
The eight recipes below are independent; jump to whichever one fits your problem:
- Audit logging
- Caching layer
- Full-text search
- Event-driven architecture
- Multi-database pattern
- Data archiving
- Rate limiting
- Subclassing for enforced denormalization
Audit Logging
Section titled “Audit Logging”Track all data changes for compliance and debugging. A dedicated audit repository records who did what, and lifecycle hooks feed it automatically on every create, update, and delete.
class AuditLogService { private auditRepo = new FirestoreRepository<AuditLog>(db, 'audit_logs');
async record(action: string, data: any, userId?: string) { await this.auditRepo.create({ action, data, userId: userId || 'system', timestamp: new Date().toISOString(), ipAddress: getCurrentIpAddress(), userAgent: getCurrentUserAgent(), }); }}
export const auditLog = new AuditLogService();
// Apply to all repositoriesuserRepo.on('afterCreate', async user => { await auditLog.record('user_created', user, user.id);});
userRepo.on('afterUpdate', async ({ id }) => { const user = await userRepo.getById(id); if (user) { await auditLog.record('user_updated', user, id); }});
userRepo.on('afterDelete', async user => { await auditLog.record('user_deleted', { id: user.id }, user.id);});Note the hook payload shapes: afterCreate receives the full created document, afterUpdate
receives only { id } (so re-read the document if you need the new values), and afterDelete
receives the full persisted document that was just removed.
Caching Layer
Section titled “Caching Layer”Add Redis caching to reduce Firestore reads. Wrap the repository so reads check the cache first and writes invalidate it.
import { Redis } from 'ioredis';
class CachedUserRepository { private repo = FirestoreRepository.withSchema<User>(db, 'users', userSchema); private cache = new Redis(process.env.REDIS_URL); private cacheTTL = 300; // 5 minutes
async getById(id: string): Promise<User | null> { // Check cache first const cached = await this.cache.get(`user:${id}`); if (cached) { return JSON.parse(cached); }
// Fallback to Firestore const user = await this.repo.getById(id); if (user) { await this.cache.setex(`user:${id}`, this.cacheTTL, JSON.stringify(user)); }
return user; }
async update(id: string, data: Partial<User>): Promise<User | null> { await this.repo.update(id, data); // Invalidate cache await this.cache.del(`user:${id}`); return this.repo.getById(id); }
async create(data: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User & { id: string }> { return this.repo.create({ ...data, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); }
// Delegate other methods to repo... query() { return this.repo.query(); }}
export const cachedUserRepo = new CachedUserRepository();userSchema here must include a required top-level id: z.string(), since
FirestoreRepository.withSchema throws at construction otherwise.
Full-Text Search
Section titled “Full-Text Search”Integrate with Algolia or Elasticsearch for full-text search. Firestore has no native full-text index, so mirror your documents into a search service and keep the two in sync with hooks.
import algoliasearch from 'algoliasearch';
class SearchService { private client = algoliasearch(process.env.ALGOLIA_APP_ID!, process.env.ALGOLIA_ADMIN_KEY!); private usersIndex = this.client.initIndex('users'); private productsIndex = this.client.initIndex('products');
async indexUser(user: User & { id: string }) { await this.usersIndex.saveObject({ objectID: user.id, name: user.name, email: user.email, status: user.status, }); }
async deleteUser(userId: string) { await this.usersIndex.deleteObject(userId); }
async searchUsers(query: string) { const { hits } = await this.usersIndex.search(query); return hits; }}
export const searchService = new SearchService();
// Sync with Algolia on user changesuserRepo.on('afterCreate', async user => { await searchService.indexUser(user);});
userRepo.on('afterUpdate', async ({ id }) => { const user = await userRepo.getById(id); if (user) { await searchService.indexUser(user); }});
userRepo.on('afterDelete', async user => { await searchService.deleteUser(user.id);});Event-Driven Architecture
Section titled “Event-Driven Architecture”Publish domain events to a message queue. Repository hooks emit events, and any number of consumers subscribe to them — decoupling side effects (email, analytics, inventory) from the write path.
import { EventEmitter } from 'events';
class EventPublisher extends EventEmitter { async publish(event: string, data: any) { this.emit(event, data); // Also publish to external queue (RabbitMQ, SQS, etc.) await messageQueue.publish(event, data); }}
export const eventPublisher = new EventPublisher();
// Publish events on repository actionsuserRepo.on('afterCreate', async user => { await eventPublisher.publish('user.created', user);});
orderRepo.on('afterCreate', async order => { await eventPublisher.publish('order.placed', order);});
// Consumers can subscribe to eventseventPublisher.on('user.created', async user => { await emailService.sendWelcomeEmail(user.email); await analyticsService.trackSignup(user);});
eventPublisher.on('order.placed', async order => { await inventoryService.reserveStock(order); await notificationService.notifyWarehouse(order);});Multi-Database Pattern
Section titled “Multi-Database Pattern”Use different databases for different data types — for example, a primary database for transactional
data and a separate database for analytics/reporting. Each database gets its own Firestore
instance, and repositories are bound to the instance they read and write.
import { getFirestore } from 'firebase-admin/firestore';
// Primary database for transactional dataexport const primaryDb = getFirestore(primaryApp);
// Analytics database for reportingexport const analyticsDb = getFirestore(analyticsApp);
// repositories/user.repository.tsexport const userRepo = FirestoreRepository.withSchema<User>(primaryDb, 'users', userSchema);
// repositories/analytics.repository.tsexport const userAnalyticsRepo = new FirestoreRepository<UserAnalytics>( analyticsDb, 'user_analytics',);
// Sync analytics datauserRepo.on('afterCreate', async user => { await userAnalyticsRepo.create({ userId: user.id, signupDate: user.createdAt, source: user.source, plan: user.plan, });});Data Archiving
Section titled “Data Archiving”Archive documents to a separate collection before permanently deleting them from the primary collection. The generic helper works against any repository.
class ArchivingService { private archiveRepo = new FirestoreRepository<ArchivedDocument>(db, 'archived_documents');
async archiveAndDelete<T extends { id?: ID }>( repo: FirestoreRepository<T>, id: string, ): Promise<void> { // Get document const doc = await repo.getById(id); if (!doc) { throw new NotFoundError('Document not found'); }
// Archive to separate collection await this.archiveRepo.create({ originalCollection: repo.getCollectionPath(), originalId: id, data: doc, archivedAt: new Date().toISOString(), });
// Permanently delete from original collection await repo.delete(id); }}
export const archivingService = new ArchivingService();
// Usageawait archivingService.archiveAndDelete(userRepo, 'user-123');The generic parameter is constrained with T extends { id?: ID } to match FirestoreRepository’s
own constraint. For stronger guarantees you can run the read, the archive write, and the delete
inside a single transaction.
Rate Limiting
Section titled “Rate Limiting”Implement rate limiting at the repository level by wrapping write methods and consuming a token before each call.
import { RateLimiterMemory } from 'rate-limiter-flexible';
class RateLimitedRepository<T extends { id?: ID }> { private rateLimiter = new RateLimiterMemory({ points: 100, // 100 requests duration: 60, // per 60 seconds });
constructor(private repo: FirestoreRepository<T>) {}
async create(data: T, userId: string): Promise<T & { id: string }> { await this.rateLimiter.consume(userId); return this.repo.create(data); }
async update(id: string, data: Partial<T>, userId: string): Promise<{ id: string }> { await this.rateLimiter.consume(userId); return this.repo.update(id, data); }
// Delegate other methods...}
export const rateLimitedUserRepo = new RateLimitedRepository(userRepo);As with the archiving helper, the generic parameter is constrained with T extends { id?: ID } so
it satisfies FirestoreRepository’s type bound.
Subclassing for Enforced Denormalization
Section titled “Subclassing for Enforced Denormalization”When you must guarantee that base document updates always include connected denormalized writes,
subclass FirestoreRepository and override write entry points so they all route through one
transactional path.
import { FirestoreRepository, ID, NotFoundError, UpdateInput, UpdateOptions,} from '@reggieofarrell/firestore-orm';import { Firestore } from 'firebase-admin/firestore';
type Order = { id: string; userId: string; status: 'pending' | 'processing' | 'cancelled'; updatedAt: string;};
type User = { id: string; lastOrderId?: string; lastOrderStatus?: string; lastOrderAt?: string;};
class OrderRepository extends FirestoreRepository<Order> { constructor( db: Firestore, private readonly userRepo: FirestoreRepository<User>, ) { super(db, 'orders'); }
// All order updates go through one transaction that also updates denormalized user fields. override async update( id: ID, data: UpdateInput<Order>, options?: UpdateOptions, ): Promise<{ id: ID } | (Order & { id: ID })> { return this.runInTransaction(async (tx, repo) => { const order = await repo.getForUpdateInTransaction(tx, id); if (!order) { throw new NotFoundError(`Order with id ${id} not found`); }
await repo.updateInTransaction(tx, id, data, options); await this.userRepo.updateInTransaction( tx, order.userId, { lastOrderId: id, lastOrderStatus: (data as Partial<Order>).status ?? order.status, lastOrderAt: new Date().toISOString(), } as UpdateInput<User>, { merge: true }, );
if (options?.returnDoc === true) { const updated = await repo.getForUpdateInTransaction(tx, id); if (!updated) throw new NotFoundError(`Order with id ${id} not found after update`); return updated; }
return { id }; }); }
// Keep patch behavior aligned by delegating to the overridden update path. override async patch( id: ID, data: UpdateInput<Order>, options?: { returnDoc?: boolean }, ): Promise<{ id: ID } | (Order & { id: ID })> { if (options?.returnDoc === true) { return this.update(id, data, { merge: true, returnDoc: true }); } return this.update(id, data, { merge: true }); }}patch deliberately takes only { returnDoc?: boolean } — patch always merges, so there is no
merge option on it. The override reproduces that always-merge behavior by delegating to update
with { merge: true }, keeping both entry points on the same transactional path.
Why this pattern is useful:
- It prevents accidental base-only writes because callers use your subclass methods, not the raw repository methods.
- It guarantees base + connected writes are atomic by committing them in one transaction.
- The same structure applies to
bulkUpdate/bulkPatch, and to create/delete paths when denormalization must be enforced there as well.