Skip to content

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

HookArgumentsCan 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:

posts.controller.ts
@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.