Model Lifecycle
Model lifecycle hooks in Adonis ODM allow you to execute custom logic at specific points during a model’s lifecycle. These hooks provide powerful ways to implement business logic, data validation, auditing, and other cross-cutting concerns.
Understanding Lifecycle Events
Available Hooks
Adonis ODM provides hooks for various model operations:
- Before/After Create - When creating new documents
- Before/After Save - When saving (create or update)
- Before/After Update - When updating existing documents
- Before/After Delete - When deleting documents
- Before/After Find - When querying documents
- Before/After Fetch - When fetching multiple documents
Hook Execution Order
// For creating a new model:
// 1. beforeSave
// 2. beforeCreate
// 3. [Database Operation]
// 4. afterCreate
// 5. afterSave
// For updating an existing model:
// 1. beforeSave
// 2. beforeUpdate
// 3. [Database Operation]
// 4. afterUpdate
// 5. afterSave
Defining Lifecycle Hooks
Basic Hook Definition
import { BaseModel, column, beforeSave, afterCreate } from 'adonis-odm'
import { DateTime } from 'luxon'
import Hash from '@ioc:Adonis/Core/Hash'
export default class User extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare name: string
@column()
declare email: string
@column()
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
// Before save hook - runs on both create and update
@beforeSave()
public static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await Hash.make(user.password)
}
}
// After create hook - runs only on create
@afterCreate()
public static async sendWelcomeEmail(user: User) {
// Send welcome email logic
await EmailService.sendWelcomeEmail(user.email, user.name)
}
}
Multiple Hooks
export default class Post extends BaseModel {
@column({ isPrimary: true })
declare _id: string
@column()
declare title: string
@column()
declare content: string
@column()
declare slug: string
@column()
declare status: string
@column()
declare authorId: string
// Multiple before save hooks
@beforeSave()
public static async generateSlug(post: Post) {
if (post.$dirty.title) {
post.slug = post.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
}
@beforeSave()
public static async validateContent(post: Post) {
if (post.$dirty.content && post.content.length < 100) {
throw new Error('Content must be at least 100 characters long')
}
}
@beforeSave()
public static async setPublishDate(post: Post) {
if (post.$dirty.status && post.status === 'published' && !post.publishedAt) {
post.publishedAt = DateTime.now()
}
}
}
Hook Types and Use Cases
Before Save Hooks
export default class User extends BaseModel {
@beforeSave()
public static async normalizeEmail(user: User) {
if (user.$dirty.email) {
user.email = user.email.toLowerCase().trim()
}
}
@beforeSave()
public static async validateAge(user: User) {
if (user.$dirty.age && user.age < 13) {
throw new Error('User must be at least 13 years old')
}
}
@beforeSave()
public static async setDefaults(user: User) {
if (user.$isLocal) { // Only for new records
user.status = user.status || 'pending'
user.role = user.role || 'user'
}
}
}
After Create Hooks
export default class User extends BaseModel {
@afterCreate()
public static async createProfile(user: User) {
await UserProfile.create({
userId: user._id,
displayName: user.name,
bio: '',
avatar: 'default.jpg'
})
}
@afterCreate()
public static async assignDefaultRole(user: User) {
const defaultRole = await Role.findBy('name', 'user')
if (defaultRole) {
await user.related('roles').attach([defaultRole._id])
}
}
@afterCreate()
public static async logUserCreation(user: User) {
await AuditLog.create({
action: 'user_created',
userId: user._id,
metadata: {
email: user.email,
name: user.name
}
})
}
}
Before Update Hooks
export default class User extends BaseModel {
@beforeUpdate()
public static async trackChanges(user: User) {
const changes = Object.keys(user.$dirty)
if (changes.length > 0) {
await ChangeLog.create({
modelType: 'User',
modelId: user._id,
changes: user.$dirty,
changedBy: user.updatedBy || 'system'
})
}
}
@beforeUpdate()
public static async validateEmailChange(user: User) {
if (user.$dirty.email) {
const existingUser = await User.query()
.where('email', user.email)
.where('_id', '!=', user._id)
.first()
if (existingUser) {
throw new Error('Email already exists')
}
}
}
}
Before Delete Hooks
export default class User extends BaseModel {
@beforeDelete()
public static async checkDependencies(user: User) {
const postCount = await Post.query()
.where('authorId', user._id)
.count()
if (postCount > 0) {
throw new Error('Cannot delete user with existing posts')
}
}
@beforeDelete()
public static async softDelete(user: User) {
// Implement soft delete instead of hard delete
user.deletedAt = DateTime.now()
user.status = 'deleted'
await user.save()
// Prevent actual deletion
return false
}
}
After Delete Hooks
export default class User extends BaseModel {
@afterDelete()
public static async cleanupRelatedData(user: User) {
// Delete user profile
await UserProfile.query()
.where('userId', user._id)
.delete()
// Delete user sessions
await UserSession.query()
.where('userId', user._id)
.delete()
// Remove from all roles
await user.related('roles').detach()
}
@afterDelete()
public static async logDeletion(user: User) {
await AuditLog.create({
action: 'user_deleted',
userId: user._id,
metadata: {
email: user.email,
deletedAt: DateTime.now()
}
})
}
}
Query Hooks
Before Find Hooks
export default class Post extends BaseModel {
@beforeFind()
public static async applyDefaultFilters(query: ModelQueryBuilder) {
// Only show published posts by default
query.where('status', 'published')
}
@beforeFind()
public static async addAuthorInfo(query: ModelQueryBuilder) {
// Always preload author information
query.preload('author', (authorQuery) => {
authorQuery.select('name', 'email')
})
}
}
Before Fetch Hooks
export default class User extends BaseModel {
@beforeFetch()
public static async excludeDeleted(query: ModelQueryBuilder) {
// Exclude soft-deleted users
query.whereNull('deletedAt')
}
@beforeFetch()
public static async applyTenantFilter(query: ModelQueryBuilder) {
// Apply tenant filtering in multi-tenant applications
const currentTenant = await getCurrentTenant()
if (currentTenant) {
query.where('tenantId', currentTenant.id)
}
}
}
Advanced Hook Patterns
Conditional Hooks
export default class Order extends BaseModel {
@beforeSave()
public static async calculateTotal(order: Order) {
// Only recalculate if items changed
if (order.$dirty.items || order.$isLocal) {
let total = 0
for (const item of order.items) {
total += item.quantity * item.price
}
order.total = total
}
}
@afterUpdate()
public static async notifyStatusChange(order: Order) {
// Only notify if status changed
if (order.$dirty.status) {
await NotificationService.sendOrderStatusUpdate(
order.customerId,
order.status,
order._id
)
}
}
}
Async Hook Chains
export default class User extends BaseModel {
@afterCreate()
public static async onUserCreated(user: User) {
// Chain multiple async operations
await Promise.all([
this.createUserProfile(user),
this.sendWelcomeEmail(user),
this.assignDefaultPermissions(user),
this.logUserCreation(user)
])
}
private static async createUserProfile(user: User) {
await UserProfile.create({
userId: user._id,
displayName: user.name
})
}
private static async sendWelcomeEmail(user: User) {
await EmailService.sendWelcomeEmail(user.email)
}
private static async assignDefaultPermissions(user: User) {
const defaultRole = await Role.findBy('name', 'user')
if (defaultRole) {
await user.related('roles').attach([defaultRole._id])
}
}
private static async logUserCreation(user: User) {
await AuditLog.create({
action: 'user_created',
userId: user._id
})
}
}
Error Handling in Hooks
export default class User extends BaseModel {
@beforeSave()
public static async validateUser(user: User) {
try {
// Validation logic
if (!user.email || !user.email.includes('@')) {
throw new Error('Invalid email format')
}
// External validation
const isValidEmail = await EmailValidator.validate(user.email)
if (!isValidEmail) {
throw new Error('Email domain not allowed')
}
} catch (error) {
// Log error for debugging
console.error('User validation failed:', error)
throw error // Re-throw to prevent save
}
}
@afterCreate()
public static async handlePostCreation(user: User) {
try {
await this.sendWelcomeEmail(user)
} catch (error) {
// Log but don't fail the creation
console.error('Failed to send welcome email:', error)
// Optionally queue for retry
await EmailQueue.add('welcome-email', {
userId: user._id,
email: user.email
})
}
}
}
Hook Context and Data
Accessing Hook Context
export default class Post extends BaseModel {
@beforeSave()
public static async setAuthor(post: Post, context: any) {
// Access current user from context
if (post.$isLocal && context.auth?.user) {
post.authorId = context.auth.user._id
}
}
@afterUpdate()
public static async logUpdate(post: Post, context: any) {
await AuditLog.create({
action: 'post_updated',
postId: post._id,
updatedBy: context.auth?.user?._id || 'system',
changes: post.$dirty
})
}
}
// Usage with context
const post = new Post()
post.title = 'New Post'
post.content = 'Post content'
await post.save({
context: {
auth: { user: currentUser }
}
})
Sharing Data Between Hooks
export default class Order extends BaseModel {
@beforeSave()
public static async calculateTotals(order: Order) {
if (order.$dirty.items) {
// Store calculation for later hooks
order.$extras.calculatedTotal = order.items.reduce(
(sum, item) => sum + (item.quantity * item.price), 0
)
order.total = order.$extras.calculatedTotal
}
}
@afterSave()
public static async updateInventory(order: Order) {
// Use calculated data from previous hook
if (order.$extras.calculatedTotal && order.$isLocal) {
for (const item of order.items) {
await Product.query()
.where('_id', item.productId)
.decrement('stock', item.quantity)
}
}
}
}
Testing Lifecycle Hooks
Hook Testing
import { test } from '@japa/runner'
import User from '#models/user'
import Hash from '@ioc:Adonis/Core/Hash'
test.group('User lifecycle hooks', () => {
test('should hash password before save', async ({ assert }) => {
const user = new User()
user.name = 'John Doe'
user.email = '[email protected]'
user.password = 'plaintext'
await user.save()
assert.notEqual(user.password, 'plaintext')
assert.isTrue(await Hash.verify(user.password, 'plaintext'))
})
test('should create profile after user creation', async ({ assert }) => {
const user = await User.create({
name: 'Jane Doe',
email: '[email protected]',
password: 'password'
})
const profile = await UserProfile.findBy('userId', user._id)
assert.exists(profile)
assert.equal(profile.displayName, 'Jane Doe')
})
test('should prevent deletion with dependencies', async ({ assert }) => {
const user = await User.create({
name: 'Author',
email: '[email protected]'
})
await Post.create({
title: 'Test Post',
content: 'Content',
authorId: user._id
})
await assert.rejects(async () => {
await user.delete()
}, 'Cannot delete user with existing posts')
})
})
Mocking Hooks for Testing
import { test } from '@japa/runner'
import sinon from 'sinon'
test.group('User hooks with mocks', () => {
test('should call email service on user creation', async ({ assert }) => {
const emailSpy = sinon.spy(EmailService, 'sendWelcomeEmail')
await User.create({
name: 'Test User',
email: '[email protected]',
password: 'password'
})
assert.isTrue(emailSpy.calledOnce)
assert.isTrue(emailSpy.calledWith('[email protected]', 'Test User'))
emailSpy.restore()
})
})
Best Practices
Hook Guidelines
// 1. Keep hooks focused and single-purpose
export default class User extends BaseModel {
// Good: Single responsibility
@beforeSave()
public static async hashPassword(user: User) {
if (user.$dirty.password) {
user.password = await Hash.make(user.password)
}
}
// Good: Single responsibility
@beforeSave()
public static async normalizeEmail(user: User) {
if (user.$dirty.email) {
user.email = user.email.toLowerCase().trim()
}
}
// Avoid: Multiple responsibilities in one hook
@beforeSave()
public static async doEverything(user: User) {
// Hash password, normalize email, validate, etc.
// This makes testing and maintenance difficult
}
}
// 2. Handle errors appropriately
export default class Order extends BaseModel {
@afterCreate()
public static async processOrder(order: Order) {
try {
// Critical operations that should fail the creation
await PaymentService.processPayment(order.paymentInfo)
} catch (error) {
// Re-throw to fail the creation
throw error
}
try {
// Non-critical operations
await EmailService.sendOrderConfirmation(order.customerEmail)
} catch (error) {
// Log but don't fail
console.error('Failed to send confirmation email:', error)
}
}
}
// 3. Use appropriate hook types
export default class Post extends BaseModel {
// Use beforeSave for data that applies to both create and update
@beforeSave()
public static async generateSlug(post: Post) {
if (post.$dirty.title) {
post.slug = slugify(post.title)
}
}
// Use beforeCreate for create-only logic
@beforeCreate()
public static async setDefaults(post: Post) {
post.status = 'draft'
post.viewCount = 0
}
// Use beforeUpdate for update-only logic
@beforeUpdate()
public static async trackModification(post: Post) {
post.modifiedAt = DateTime.now()
}
}
Next Steps
- Database Transactions - Use hooks with transactions
- Model Validation - Combine hooks with validation
- Testing - Test your lifecycle hooks
- BaseModel API - Complete lifecycle API reference