NestJS: Hooks
Hooks handle side effects around CRUD operations — notifications, cache invalidation, audit logging, data enrichment. For business logic use service overrides, for response transformation use Data Mapper.
Creating hooks
Extend RelayerHooks<TEntity, TEntities, TCtx?>. The optional third generic narrows the ctx argument across every hook method. By default TCtx is RequestContext; pass your own to get a fully typed app context — see Typed context below. All hook methods are optional and support sync and async.
import { Injectable, Logger } from '@nestjs/common';import { RelayerHooks, type AggregateOptions, type FirstOptions, type ManyOptions, type RequestContext, type Where, type WhereOptions,} from '@relayerjs/nestjs-crud';
import { PostEntity, type EM } from '../entities';
@Injectable()export class PostHooks extends RelayerHooks<PostEntity, EM> { private readonly logger = new Logger(PostHooks.name);
constructor( private readonly events: EventEmitter2, private readonly cache: CacheManager, ) { super(); }
// ... implement any hooks you need}Available hooks
beforeCreate
Called before inserting a new record. Return modified data to change what gets saved.
async beforeCreate( data: Partial<PostEntity>, ctx: RequestContext,): Promise<Partial<PostEntity> | void> { data.slug = slugify(data.title!); return data;}afterCreate
Called after a record is inserted.
async afterCreate(entity: PostEntity, ctx: RequestContext): Promise<void> { this.logger.log(`Post created: ${entity.id} - ${entity.title}`); await this.events.emit('post.created', entity);}beforeUpdate
Called before updating. Receives both the data to update and the where clause. Return modified data.
async beforeUpdate( data: Partial<PostEntity>, where: Where<PostEntity, EM>, ctx: RequestContext,): Promise<Partial<PostEntity> | void> { data.updatedAt = new Date(); return data;}afterUpdate
Called after a record is updated.
async afterUpdate(entity: PostEntity, ctx: RequestContext): Promise<void> { await this.cache.del(`posts:${entity.id}`);}beforeDelete
Called before deleting.
async beforeDelete( where: Where<PostEntity, EM>, ctx: RequestContext,): Promise<void> { this.logger.log(`Deleting post with where: ${JSON.stringify(where)}`);}afterDelete
Called after a record is deleted.
async afterDelete(entity: PostEntity, ctx: RequestContext): Promise<void> { await this.cache.del(`posts:${entity.id}`); await this.events.emit('post.deleted', entity);}beforeFind
Called before findMany (list endpoint). Receives the full query options.
async beforeFind( options: ManyOptions<PostEntity, EM>, ctx: RequestContext,): Promise<void> { this.logger.log(`Finding posts with options: ${JSON.stringify(options)}`);}afterFind
Called after findMany. Return a modified list to filter or transform results.
async afterFind( entities: PostEntity[], ctx: RequestContext,): Promise<PostEntity[] | void> { return entities.filter((e) => !e.isArchived);}beforeFindOne
Called before findFirst (detail endpoint).
async beforeFindOne( options: FirstOptions<PostEntity, EM>, ctx: RequestContext,): Promise<void> { this.logger.log(`Finding post: ${JSON.stringify(options)}`);}afterFindOne
Called after findFirst. Return a modified entity.
async afterFindOne( entity: PostEntity, ctx: RequestContext,): Promise<PostEntity | void> { // e.g. track view count}beforeCount
Called before count queries.
async beforeCount( options: WhereOptions<PostEntity, EM>, ctx: RequestContext,): Promise<void> { this.logger.log(`Counting posts`);}beforeAggregate
Called before aggregate queries.
async beforeAggregate( options: AggregateOptions<PostEntity, EM>, ctx: RequestContext,): Promise<void> { this.logger.log(`Aggregating: ${JSON.stringify(options)}`);}afterAggregate
Called after aggregate. Return modified result.
async afterAggregate( result: unknown, ctx: RequestContext,): Promise<unknown | void> { this.logger.log(`Aggregate result: ${JSON.stringify(result)}`);}Summary table
| Hook | Arguments | Can modify? |
|---|---|---|
beforeCreate | (data: Partial<TEntity>, ctx) | Return modified data |
afterCreate | (entity: TEntity, ctx) | |
beforeUpdate | (data: Partial<TEntity>, where: Where, ctx) | Return modified data |
afterUpdate | (entity: TEntity, ctx) | |
beforeDelete | (where: Where, ctx) | |
afterDelete | (entity: TEntity, ctx) | |
beforeFind | (options: ManyOptions, ctx) | |
afterFind | (entities: TEntity[], ctx) | Return modified list |
beforeFindOne | (options: FirstOptions, ctx) | |
afterFindOne | (entity: TEntity, ctx) | Return modified entity |
beforeCount | (options: WhereOptions, ctx) | |
beforeAggregate | (options: AggregateOptions, ctx) | |
afterAggregate | (result: unknown, ctx) | Return modified result |
beforeRelation | (operation, relationName, ids, ctx) | Return modified ids |
afterRelation | (operation, relationName, ids, ctx) |
All option types (Where, ManyOptions, FirstOptions, WhereOptions, AggregateOptions) are generic over <TEntity, TEntities>. Relation hooks use RelationOperation, RelationKeys<TEntity, TEntities>, and RelationId[].
Typed context
RelayerHooks accepts a third generic TCtx which narrows the ctx argument across every hook method. Pair it with the matching controller override (buildContext from CRUD Controller > Typed context) and your hooks receive a fully typed app context — no as casts.
import { Injectable, Logger } from '@nestjs/common';import { RelayerHooks } from '@relayerjs/nestjs-crud';
import type { AppContext } from '../../common/app-context';import { PostEntity, type EM } from '../entities';
@Injectable()export class PostHooks extends RelayerHooks<PostEntity, EM, AppContext> { private readonly logger = new Logger(PostHooks.name);
async afterCreate(entity: PostEntity, ctx: AppContext): Promise<void> { this.logger.log(`Post ${entity.id} created by user ${ctx.currentUser.id}`); }
async beforeUpdate( data: Partial<PostEntity>, where: Where<PostEntity, EM>, ctx: AppContext, ): Promise<Partial<PostEntity>> { return { ...data, updatedBy: ctx.currentUser.id }; }}ctx is now typed as AppContext everywhere — beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDelete, afterDelete, beforeFind, afterFind, beforeFindOne, afterFindOne, beforeCount, beforeAggregate, afterAggregate, beforeRelation, afterRelation. Wrong field accesses become compile errors.
The matching RelayerController must declare the same TCtx as its 4th generic and override buildContext(request) to produce it — see CRUD Controller > Typed context for the request → context flow.
Registration
Register in module providers and reference in @CrudController:
@CrudController<PostEntity, EM>({ model: PostEntity, hooks: PostHooks,})export class PostsController extends RelayerController<PostEntity, EM> { ... }
// posts.module.ts@Module({ providers: [PostsService, PostHooks],})export class PostsModule {}Hooks are resolved via NestJS DI — constructor injection works.
Relation hooks
beforeRelation and afterRelation fire for connect, disconnect, and set operations — both from dedicated endpoints and inline PATCH:
import { RelayerHooks, type RelationId, type RelationKeys, type RelationOperation, type RequestContext,} from '@relayerjs/nestjs-crud';
@Injectable()export class PostHooks extends RelayerHooks<PostEntity, EM> { beforeRelation( operation: RelationOperation, // 'connect' | 'disconnect' | 'set' relationName: RelationKeys<PostEntity, EM>, // e.g. 'postCategories' ids: RelationId[], // [1, 2] or [{ _id: 1, isPrimary: true }] ctx: RequestContext, ) { this.logger.log(`${operation} on ${relationName}: [${ids}]`); // Return modified ids to override input, or void to pass through }
afterRelation( operation: RelationOperation, relationName: RelationKeys<PostEntity, EM>, ids: RelationId[], ctx: RequestContext, ) { // Side effects after the relation was updated }}See Relations for the full guide.