Lifecycle Hooks
Intercept operations to transform data or trigger side effects.
Hook Execution Order
Insert Flow
1. beforeValidate(rawInput) → Transform raw input (e.g., generate slug)
2. Zod Validation → Schema validation with defaults/transforms
3. afterValidate(validatedDoc) → Post-validation transforms
4. Timestamps Applied → createdAt/updatedAt set (if enabled)
5. beforeInsert(doc) → Final modifications before insert
6. MongoDB Insert → Document inserted
7. afterInsert(doc) → Side effects (logging, notifications)Update Flow
1. beforeUpdate(filter, update) → Modify filter/update
2. Partial Validation → Validate $set fields
3. Timestamps Applied → updatedAt set (if enabled)
4. MongoDB Update → Document updated
5. afterUpdate(doc) → Side effects (only for updateOne)Delete Flow
1. beforeDelete(filter) → Modify filter
2. MongoDB Delete → Document(s) deleted
3. afterDelete(count) → Side effectsDefining Hooks
typescript
const Posts = collection({
name: 'posts',
schema: {
_id: field.id(),
title: field.string(),
slug: field.string(),
publishedAt: field.date().optional(),
},
hooks: {
// Transform BEFORE validation (generate slug from title)
beforeValidate: (doc) => ({
...doc,
slug: doc.title.toLowerCase().replace(/\s+/g, '-'),
}),
// Transform AFTER validation (validated doc available)
afterValidate: (doc) => ({
...doc,
// Can access validated/defaulted fields here
}),
// Final modifications before insert
beforeInsert: (doc) => ({
...doc,
searchIndex: `${doc.title} ${doc.slug}`.toLowerCase(),
}),
// Side effects after insert
afterInsert: async (doc) => {
await notifySubscribers(doc);
},
// Modify filter/update before updating
beforeUpdate: (filter, update) => ({
filter,
update: {
...update,
$set: { ...update.$set, lastModifiedBy: getCurrentUserId() },
},
}),
// React to updates
afterUpdate: async (doc) => {
if (doc) await logAuditTrail('update', doc._id);
},
// Modify filter before delete
beforeDelete: (filter) => {
console.log('Deleting:', filter);
return filter;
},
// React to deletes
afterDelete: async (count) => {
console.log(`Deleted ${count} documents`);
},
},
});Hook Reference
| Hook | Triggered By | Receives | Must Return |
|---|---|---|---|
beforeValidate | insertOne, insertMany | raw input | document |
afterValidate | insertOne, insertMany | validated document | document |
beforeInsert | insertOne, insertMany | document (after timestamps) | document |
afterInsert | insertOne, insertMany | inserted document | nothing |
beforeUpdate | updateOne only | (filter, update) | { filter, update } |
afterUpdate | updateOne only | updated document or null | nothing |
beforeDelete | deleteOne, deleteMany | filter | filter |
afterDelete | deleteOne, deleteMany | deleted count | nothing |
Async Hooks
All hooks can be async:
typescript
hooks: {
beforeInsert: async (doc) => {
const exists = await checkExternalService(doc.email);
if (exists) {
throw new Error('Email already registered');
}
return doc;
},
afterInsert: async (doc) => {
await sendWelcomeEmail(doc.email);
await analytics.track('user_created', { userId: doc._id });
},
}Common Patterns
Auto-Generate Slug
typescript
hooks: {
beforeValidate: (doc) => ({
...doc,
slug: doc.slug || doc.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}),
}Audit Trail
typescript
hooks: {
beforeInsert: (doc) => ({
...doc,
createdBy: getCurrentUserId(),
}),
beforeUpdate: (filter, update) => ({
filter,
update: {
...update,
$set: {
...update.$set,
updatedBy: getCurrentUserId(),
},
},
}),
}Cascade Delete
typescript
hooks: {
afterDelete: async (count, filter) => {
// Delete related documents
await Comments.deleteMany({ postId: filter._id });
await Likes.deleteMany({ postId: filter._id });
},
}Validation with External Data
typescript
hooks: {
beforeValidate: async (doc) => {
// Fetch additional data
const category = await Categories.findById(doc.categoryId);
if (!category) {
throw new Error('Invalid category');
}
return {
...doc,
categoryName: category.name,
};
},
}Normalize Data
typescript
hooks: {
beforeValidate: (doc) => ({
...doc,
email: doc.email.toLowerCase().trim(),
tags: doc.tags?.map(t => t.toLowerCase().trim()),
}),
}Important Notes
Operations Without Hooks
The following operations do not trigger any hooks:
updateMany- No update hooksfindOneAndUpdate- Atomic operationfindOneAndDelete- Atomic operationfindOneAndReplace- Atomic operationreplaceOne- No hooksbulkWrite- No hooks (uses Zod-only validation)
Validation Hooks on Insert Only
beforeValidate and afterValidate only run on insertOne and insertMany. They do not run on update operations.
Update Hooks on updateOne Only
beforeUpdate and afterUpdate only run for updateOne, not for updateMany.
Error Handling in Hooks
If a hook throws an error, the operation is aborted:
typescript
hooks: {
beforeInsert: (doc) => {
if (doc.role === 'admin' && !isCurrentUserSuperAdmin()) {
throw new Error('Only super admins can create admin users');
}
return doc;
},
}
// Usage
try {
await Users.insertOne({ name: 'Alice', role: 'admin' });
} catch (error) {
console.log(error.message); // 'Only super admins can create admin users'
}