Skip to content

Lifecycle Hooks

Inject custom logic at specific points in the data lifecycle — auditing, enrichment, validation, and cleanup — without cluttering your business logic.

Hooks let you observe and shape writes as they flow through the repository. Register them with on(event, fn); the callback may be synchronous or async (the repository awaits it). A single event can carry multiple listeners, and they run in registration order.

userRepo.on('afterCreate', async (user, context) => {
// context.event === 'afterCreate'; context.execution === 'direct'
await auditLog.record('user_created', user);
});

Every callback receives a second argument: a typed HookContext correlated with the registration event.

Field Meaning
event The registration event (useful for shared multi-event handlers).
execution 'direct' for normal repository/query writes; 'transaction' only on before* hooks fired from *InTransaction helpers.
retryable false for direct; true for transaction before-hooks (the Admin SDK may re-run the callback under contention).
attempt Present only on the transaction branch: a 1-based count of how many times runInTransaction entered the Admin SDK callback, or null when the caller owns a raw Admin SDK transaction. Diagnostic only — never an idempotency key.

One-argument callbacks remain source-compatible (TypeScript allows fewer parameters).

  • Hooks run in registration order, each awaited sequentially.
  • The first thrown/rejected hook stops later hooks for that event (fail-fast). There is no best-effort or aggregate delivery.
  • Transaction before* hooks may run once per callback attempt under contention. Key side effects by a business / write identity stored atomically with the data — not by attempt.
  • after* hooks are postcommit, in-process, and not durable across process crash. Prefer idempotent side effects, or a durable outbox when available (tracked separately as #80).

When an outcome-sensitive failure occurs (before-hook, after-hook, partial fixed batch, or postcommit { returnDoc: true } read-back), the repository throws WriteOutcomeError with a discriminated outcome and the original failure as cause. Ordinary validation / conflict / precondition errors remain top-level when no write committed and no hook is the failed phase. See Error Handling.

  • before* hooks run first and can enrich or normalize the payload before schema validation.
  • The validated payload is the one persisted to Firestore.
  • after* hooks run only after a successful write.
  • Bulk operations fire the corresponding beforeBulk* / afterBulk* events with the same ordering guarantees.

Because before* runs before validation, it is the correct place to fill in defaults, coerce values, or reject a write early. See CRUD operations for the write methods these hooks wrap.

  • Single operations: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete
  • Bulk operations: beforeBulkCreate, afterBulkCreate, beforeBulkUpdate, afterBulkUpdate, beforeBulkDelete, afterBulkDelete

upsert(id, data) does an existence pre-read and then takes one of two branches, so which hooks fire depends on whether the document already exists:

Document at id Hooks fired
does not exist beforeCreate → afterCreate
already exists beforeUpdate → afterUpdate

If a rule must hold for every upsert, register it on both pairs — or use createWithId (always create-only) when you want a single, predictable branch.

Three write paths deliberately fire nothing:

  • bulkWrite — high-throughput, non-atomic BulkWriter path. If any bulk hook is registered on the repository, the call throws unless you pass { skipHooks: true } to acknowledge that those hooks will not fire.
  • recursiveDelete — deletes a document and every descendant; no per-document or bulk delete hooks run (the SDK streams name-only snapshots and descendants live in collections this repository may not model).
  • recursiveDeleteCollection — deletes every document in this repository’s collection and every descendant; the same name-only / cross-collection reasons apply, so no delete hooks can honestly supply modeled payloads.

Do not assume every write goes through the hook table above.

Both recursive deletes throw when a write interceptor is registered on the repository, rather than deleting past it silently — the same reason bulkWrite throws on a registered bulk hook, applied to a guarantee instead of a notification. So an invariant enforced by an interceptor holds on these paths by refusing them; an invariant enforced by hooks does not hold on them at all.

Event Payload
beforeCreate The create payload (before validation), plus a readonly id when the caller supplied one (createWithId, upsert). Absent for auto-id create(), so the field is optional.
afterCreate The parsed write output (z.output<writeSchema>, coercions/defaults applied) plus the generated id
beforeUpdate The update payload plus the target id (data & { id })
afterUpdate { id }
beforeDelete / afterDelete The full persisted document (FirestoreDocument<T>)
beforeBulkCreate An array of create payloads (before validation), each with a readonly id — ids are generated before the hook runs, so they are always present here
afterBulkCreate An array of parsed write outputs (z.output<writeSchema>), each plus its generated id
beforeBulkUpdate { id, data }[]
afterBulkUpdate { ids: ID[] }
beforeBulkDelete / afterBulkDelete { ids: ID[]; documents: FirestoreDocument<T>[] }

Delete hooks (single and bulk) receive the full persisted document(s) as they existed before deletion, so cleanup logic has access to every field, not just the id.

Hook payloads protect identity and accounting:

  • Identity is read-only. The id / ids on a payload cannot be repointed by a hook, and the event envelopes and bulk arrays are frozen — a hook cannot reorder, splice, or replace entries to redirect or suppress a write.
  • Before-update hooks may mutate data in place (entry.data.someField = …) but may not replace the whole data object.
  • Delete payloads are observe-only and deep-frozen, so a beforeDelete/beforeBulkDelete hook cannot forge nested data that a later afterDelete/afterBulkDelete hook (or an audit/outbox consumer) then observes. Limitation: a class-instance field value returned by a readConverter (e.g. a mutable Date, Map, or custom class) is not cloned or frozen — treat such values as observe-only by convention.
// Log all user creations
userRepo.on('afterCreate', async user => {
console.log(`User created: ${user.id}`);
await auditLog.record('user_created', user);
});
// Send welcome email
userRepo.on('afterCreate', async user => {
await sendWelcomeEmail(user.email);
});
// Validate business rules before update
orderRepo.on('beforeUpdate', data => {
if (data.status === 'shipped' && !data.trackingNumber) {
throw new Error('Tracking number required for shipped orders');
}
});
// Enrich create payload before validation (e.g., timestamps/defaults)
orderRepo.on('beforeCreate', data => {
data.createdAt = new Date().toISOString();
data.updatedAt = new Date().toISOString();
});
// Clean up related data after deletion
userRepo.on('afterDelete', async user => {
await orderRepo.query().where('userId', '==', user.id).delete();
});

In the last example, query().delete() is a query-level bulk write: it runs beforeBulkDelete / afterBulkDelete on the target repository, but does not run the per-document beforeDelete / afterDelete hooks — which is exactly what you want here, since it avoids re-triggering per-document cleanup logic recursively.

query().update(data) runs beforeBulkUpdate and afterBulkUpdate; query().delete() runs beforeBulkDelete and afterBulkDelete. beforeBulkUpdate may mutate the update payload before it is validated and written, afterBulkUpdate receives { ids } for the written documents, and the bulk-delete hooks receive { ids, documents }. The per-document before/afterUpdate and before/afterDelete hooks do not run on query-level writes — use the single-document methods when you need those. Separately, bulkWrite, recursiveDelete, and recursiveDeleteCollection run no hooks at all (see above) — and all three throw when a write interceptor is registered. See Queries.

Hooks are wired into the per-document and bulk methods on the repository. One path differs from that standard flow:

  • Transactions — before* only. Inside runInTransaction((tx, repo) => { ... }), the transaction-scoped repo’s write helpers (createInTransaction, updateInTransaction, patchInTransaction, deleteInTransaction) do run their before* hooks (before validation and the staged write). Their after* hooks do not run — the transaction has not committed while the callback executes, so post-commit side effects belong after runInTransaction resolves. See Transactions.