Lifecycle hooks & validation
before/after hooks, validateCreate/validateUpdate, and DTO-to-entity mappers — all optional overrides.
The base class exposes a small set of protected methods you can override in your service. Each is optional and has a sensible default (no-op or pass-through).
Validation
class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto> {
protected async validateCreate(data: CreateUserDto): Promise<void> {
if (!data.email.includes('@')) {
throw new ValidationFailedException('Invalid email');
}
}
protected async validateUpdate(id: number, data: UpdateUserDto): Promise<void> {
if (data.email && !data.email.includes('@')) {
throw new ValidationFailedException('Invalid email');
}
}
}Both validateCreate and validateUpdate may return void or throw. Any throw is propagated to the caller and aborts the write — no insert, no update.
Validation runs before beforeCreate / beforeUpdate and before
the SQL. Use it for fast, synchronous input validation. For heavier
work (DB lookups, async checks), use beforeCreate / beforeUpdate
instead — they'll run in the same code path but won't be confused with
input shape validation.
DTO → entity mapping
The two mapper methods let you transform the DTO into the row to persist. By default, both return a shallow copy of the input.
class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto> {
protected mapCreateDtoToEntity(data: CreateUserDto) {
return {
...data,
email: data.email.toLowerCase().trim(),
status: 'pending',
};
}
protected mapUpdateDtoToEntity(data: UpdateUserDto) {
const result: Record<string, any> = {};
if (data.name !== undefined) result.name = data.name;
if (data.email !== undefined) result.email = data.email.toLowerCase().trim();
return result;
}
}Mappers are the right place to:
- Normalize input (lowercase emails, trim whitespace).
- Set fields the caller can't control (
status: 'pending',createdBy). - Strip fields the DTO allows but the entity doesn't have.
before / after hooks
Each write has a before* and after* pair. They fire in this order:
validateCreate
→ mapCreateDtoToEntity
→ beforeCreate
→ SQL INSERT
→ afterCreateafterCreate receives the freshly-created row. afterUpdate and afterRestore receive the updated row. afterDelete and afterSoftDelete receive the id.
class UsersService extends SqlBaseCrudService<User, CreateUserDto, UpdateUserDto> {
protected async beforeCreate(data: CreateUserDto) {
// Mutate the data in place, or return a new value.
return { ...data, slug: slugify(data.name) };
}
protected async afterCreate(entity: User): Promise<void> {
await this.events.emit('user.created', entity);
}
protected async afterUpdate(entity: User): Promise<void> {
await this.events.emit('user.updated', entity);
}
protected async beforeDelete(id: number): Promise<void> {
// e.g. publish a deletion intent, write an audit log.
}
}If beforeCreate returns a value, that value is used instead of the input. Returning undefined / not returning keeps the input as-is.
Skipping hooks
Pass options.hooks to skip a hook temporarily:
await users.create(dto, { hooks: { skipBefore: true, skipAfter: true } });This is useful for migrations or admin scripts that shouldn't fire domain events.
Full hook list
| Hook | Argument | Returns | Fires on |
|---|---|---|---|
validateCreate(data) | CreateDto | void or throws | create(), massCreate() (per row) |
validateUpdate(id, data) | id, UpdateDto | void or throws | update(), massUpdate() (per row) |
mapCreateDtoToEntity(data) | CreateDto | Record<string, any> | create(), massCreate() (per row) |
mapUpdateDtoToEntity(data) | UpdateDto | Record<string, any> | update(), massUpdate() (per row) |
beforeCreate(data) | CreateDto | CreateDto (or mutates) | create(), massCreate() (per row) |
afterCreate(entity) | T | void | create(), massCreate() (per row) |
beforeUpdate(id, data) | id, UpdateDto | UpdateDto (or mutates) | update(), massUpdate() (per row) |
afterUpdate(entity) | T | void | update(), massUpdate() (per row) |
beforeDelete(id) | id | void | delete(), massDelete() (per row) |
afterDelete(id) | id | void | delete(), massDelete() (per row) |
beforeSoftDelete(id) | id | void | softDelete(), massSoftDelete() (per row) |
afterSoftDelete(id) | id | void | softDelete(), massSoftDelete() (per row) |
beforeRestore(id) | id | void | restore(), massRestore() (per row) |
afterRestore(entity) | T | void | restore(), massRestore() (per row) |
All hooks are protected so they can't be called from outside the class. They're meant to be overridden in your service subclass.