Skip to content

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 effects

Defining 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

HookTriggered ByReceivesMust Return
beforeValidateinsertOne, insertManyraw inputdocument
afterValidateinsertOne, insertManyvalidated documentdocument
beforeInsertinsertOne, insertManydocument (after timestamps)document
afterInsertinsertOne, insertManyinserted documentnothing
beforeUpdateupdateOne only(filter, update){ filter, update }
afterUpdateupdateOne onlyupdated document or nullnothing
beforeDeletedeleteOne, deleteManyfilterfilter
afterDeletedeleteOne, deleteManydeleted countnothing

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 hooks
  • findOneAndUpdate - Atomic operation
  • findOneAndDelete - Atomic operation
  • findOneAndReplace - Atomic operation
  • replaceOne - No hooks
  • bulkWrite - 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'
}

Released under the MIT License.