Skip to content

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:

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.

services/audit-log.service.ts
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 repositories
userRepo.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.

Add Redis caching to reduce Firestore reads. Wrap the repository so reads check the cache first and writes invalidate it.

repositories/cached-user.repository.ts
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.

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.

services/search.service.ts
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 changes
userRepo.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);
});

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.

services/event-publisher.service.ts
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 actions
userRepo.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 events
eventPublisher.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);
});

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.

config/database.ts
import { getFirestore } from 'firebase-admin/firestore';
// Primary database for transactional data
export const primaryDb = getFirestore(primaryApp);
// Analytics database for reporting
export const analyticsDb = getFirestore(analyticsApp);
// repositories/user.repository.ts
export const userRepo = FirestoreRepository.withSchema<User>(primaryDb, 'users', userSchema);
// repositories/analytics.repository.ts
export const userAnalyticsRepo = new FirestoreRepository<UserAnalytics>(
analyticsDb,
'user_analytics',
);
// Sync analytics data
userRepo.on('afterCreate', async user => {
await userAnalyticsRepo.create({
userId: user.id,
signupDate: user.createdAt,
source: user.source,
plan: user.plan,
});
});

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();
// Usage
await 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.

Implement rate limiting at the repository level by wrapping write methods and consuming a token before each call.

decorators/rate-limited-repository.ts
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.

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.