Route Subscribers
Route subscribers intercept operations at the controller level, providing access to the HTTP request context including headers, IP address, and authenticated user information.
Creating a Route Subscriber
Extend ApiRouteSubscriberBase and decorate with @ApiRouteSubscriber:
import { Injectable } from "@nestjs/common";
import { ApiRouteSubscriber, ApiRouteSubscriberBase, TApiSubscriberRouteBeforeCreateContext, TApiSubscriberRouteAfterCreateContext } from "@elsikora/nestjs-crud-automator";
import { PostEntity } from "./post.entity";
@Injectable()
@ApiRouteSubscriber({
entity: PostEntity,
priority: 10, // Higher priority executes first
})
export class PostAuditSubscriber extends ApiRouteSubscriberBase<PostEntity> {
// Implement hook methods using helper types
}Available Hooks
Before Hooks
Called before the main operation:
onBeforeCreate
async onBeforeCreate(
context: TApiSubscriberRouteBeforeCreateContext<PostEntity>
): Promise<TApiSubscriberRouteBeforeCreateContext<PostEntity>["result"] | undefined> {
const { body } = context.result;
// Fully typed access to route metadata
const method = context.DATA.method;
const entityMetadata = context.DATA.entityMetadata;
// Before hooks have route metadata and optional authorizationDecision in DATA
// Request payload lives in context.result (body/parameters/query)
return context.result;
}onBeforeUpdate
async onBeforeUpdate(
context: TApiSubscriberRouteBeforeUpdateContext<PostEntity>
): Promise<TApiSubscriberRouteBeforeUpdateContext<PostEntity>["result"] | undefined> {
const { body } = context.result;
// Track last modified
body.lastModifiedAt = new Date();
return context.result;
}onBeforePartialUpdate
Same payload as onBeforeUpdate (parameters + partial body). Use it to intercept PATCH routes.
onBeforeGet, onBeforeGetList, onBeforeDelete
async onBeforeGet(
context: TApiSubscriberRouteBeforeGetContext<PostEntity>
): Promise<TApiSubscriberRouteBeforeGetContext<PostEntity>["result"] | undefined> {
// Log access with route metadata
console.log("Accessing post via route:", context.DATA.methodName, context.result.parameters);
return context.result;
}
async onBeforeDelete(
context: TApiSubscriberRouteBeforeDeleteContext<PostEntity>
): Promise<TApiSubscriberRouteBeforeDeleteContext<PostEntity>["result"] | undefined> {
// Validation logic before deletion
console.log("Attempting to delete post:", context.result.parameters);
return context.result;
}After Hooks
Called after successful operation. After hooks have access to extended DATA with authentication, authorizationDecision, headers, IP, and the last request payload (body/parameters/query):
onAfterCreate
async onAfterCreate(
context: TApiSubscriberRouteAfterCreateContext<PostEntity>
): Promise<PostEntity | undefined> {
const post = context.result;
// Access authenticated user (available in after hooks)
const user = context.DATA.authenticationRequest?.user;
const clientIp = context.DATA.ip;
const userAgent = context.DATA.headers["user-agent"];
// Send notification
await this.notificationService.send({
to: post.authorId,
message: `Your post "${post.title}" was created successfully`,
});
// Log audit with full context
console.log(`User ${user?.id} created post ${post.id} from ${clientIp}`);
return post;
}onAfterUpdate
async onAfterUpdate(
context: TApiSubscriberRouteAfterUpdateContext<PostEntity>
): Promise<PostEntity | undefined> {
const post = context.result;
const user = context.DATA.authenticationRequest?.user;
// Invalidate cache
await this.cacheService.del(`post:${post.id}`);
// Notify followers
await this.notifyFollowers(post, user?.id);
return post;
}onAfterPartialUpdate
Same payload as onAfterUpdate, but runs for PATCH routes.
onAfterGet, onAfterGetList, onAfterDelete
async onAfterGetList(
context: TApiSubscriberRouteAfterGetListContext<PostEntity>
): Promise<IApiGetListResponseResult<PostEntity> | undefined> {
const response = context.result;
// Fully typed access to extended DATA
const user = context.DATA.authenticationRequest?.user;
const ip = context.DATA.ip;
// Track analytics
await this.analyticsService.trackView("post_list", {
count: response.items.length,
userId: user?.id,
ip,
});
return response;
}Error Hooks
Called when an operation fails:
onBeforeErrorCreate, onAfterErrorCreate
async onBeforeErrorCreate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity, IApiSubscriberRouteExecutionContextData<PostEntity>>,
error: Error
): Promise<void> {
// Log before error propagates
console.error("Post creation is about to fail:", {
error: error.message,
data: context.DATA,
});
}
async onAfterErrorCreate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity, IApiSubscriberRouteExecutionContextDataExtended<PostEntity, PostEntity>>,
error: Error
): Promise<void> {
// Cleanup or notify after error
await this.notificationService.notifyAdmins({
type: "post_creation_failed",
error: error.message,
user: context.DATA.authenticationRequest?.user,
});
}Other Error Hooks
async onBeforeErrorUpdate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onAfterErrorUpdate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onBeforeErrorPartialUpdate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onAfterErrorPartialUpdate(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onBeforeErrorGet(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onAfterErrorGet(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onBeforeErrorGetList(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onAfterErrorGetList(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onBeforeErrorDelete(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }
async onAfterErrorDelete(
context: IApiSubscriberRouteErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> { /* ... */ }Accessing Request Data
Route subscribers have different DATA types for before and after hooks:
Before Hooks DATA
async onBeforeCreate(
context: TApiSubscriberRouteBeforeCreateContext<PostEntity>
): Promise<TApiSubscriberRouteBeforeCreateContext<PostEntity>["result"] | undefined> {
// Base route metadata (+ optional authorizationDecision)
const method = context.DATA.method;
const methodName = context.DATA.methodName;
const entityMetadata = context.DATA.entityMetadata;
const properties = context.DATA.properties;
return context.result;
}After Hooks DATA
async onAfterCreate(
context: TApiSubscriberRouteAfterCreateContext<PostEntity>
): Promise<PostEntity | undefined> {
// Extended DATA with request context
const user = context.DATA.authenticationRequest?.user;
const contentType = context.DATA.headers["content-type"];
const userAgent = context.DATA.headers["user-agent"];
const clientIp = context.DATA.ip;
// Plus all base metadata
const method = context.DATA.method;
const entityMetadata = context.DATA.entityMetadata;
return context.result;
}Practical Examples
IP-Based Access Control
Note: IP address is available in extended DATA for after/error hooks. For before hooks, read it from the request payload (context.result) if you type it explicitly.
@Injectable()
@ApiRouteSubscriber({ entity: PostEntity, priority: 100 })
export class IpAuditSubscriber extends ApiRouteSubscriberBase<PostEntity> {
async onAfterCreate(context: TApiSubscriberRouteAfterCreateContext<PostEntity>): Promise<PostEntity | undefined> {
const clientIp = context.DATA.ip;
const user = context.DATA.authenticationRequest?.user;
// Log access with IP
await this.auditService.log({
action: "create",
entityId: context.result.id,
userId: user?.id,
ip: clientIp,
});
return context.result;
}
}Rate Limiting Per User
@Injectable()
@ApiRouteSubscriber({ entity: PostEntity, priority: 90 })
export class UserRateLimitSubscriber extends ApiRouteSubscriberBase<PostEntity> {
private readonly userLimits = new Map<string, { count: number; resetAt: number }>();
async onBeforeCreate(context: TApiSubscriberRouteBeforeCreateContext<PostEntity>): Promise<TApiSubscriberRouteBeforeCreateContext<PostEntity>["result"] | undefined> {
const userId = context.result.authenticationRequest?.user?.id;
if (!userId) return context.result;
const now = Date.now();
const userLimit = this.userLimits.get(userId);
if (userLimit && userLimit.resetAt > now) {
if (userLimit.count >= 10) {
throw new TooManyRequestsException("Rate limit exceeded");
}
userLimit.count++;
} else {
this.userLimits.set(userId, {
count: 1,
resetAt: now + 60000, // 1 minute
});
}
return context.result;
}
}Content Moderation
@Injectable()
@ApiRouteSubscriber({ entity: PostEntity, priority: 80 })
export class ContentModerationSubscriber extends ApiRouteSubscriberBase<PostEntity> {
constructor(private readonly moderationService: ModerationService) {
super();
}
async onBeforeCreate(context: TApiSubscriberRouteBeforeCreateContext<PostEntity>): Promise<TApiSubscriberRouteBeforeCreateContext<PostEntity>["result"] | undefined> {
const { body } = context.result;
const isInappropriate = await this.moderationService.check(body.content);
if (isInappropriate) {
throw new BadRequestException("Content contains inappropriate material");
}
return context.result;
}
}Notification System
@Injectable()
@ApiRouteSubscriber({ entity: PostEntity, priority: 50 })
export class PostNotificationSubscriber extends ApiRouteSubscriberBase<PostEntity> {
constructor(
private readonly notificationService: NotificationService,
private readonly followerService: FollowerService,
) {
super();
}
async onAfterCreate(context: TApiSubscriberRouteAfterCreateContext<PostEntity>): Promise<PostEntity> {
const post = context.result;
const author = context.DATA.authenticationRequest?.user;
// Notify followers
const followers = await this.followerService.getFollowers(author.id);
for (const follower of followers) {
await this.notificationService.send({
userId: follower.id,
type: "new_post",
data: { postId: post.id, authorId: author.id },
});
}
return post;
}
async onAfterUpdate(context: TApiSubscriberRouteAfterUpdateContext<PostEntity>): Promise<PostEntity> {
const post = context.result;
// Notify post author of update
await this.notificationService.send({
userId: post.authorId,
type: "post_updated",
data: { postId: post.id },
});
return post;
}
}Priority Ordering
Control execution order with priority:
// High priority (executes first)
@ApiRouteSubscriber({ entity: PostEntity, priority: 100 })
export class SecuritySubscriber extends ApiRouteSubscriberBase<PostEntity> {}
// Medium priority
@ApiRouteSubscriber({ entity: PostEntity, priority: 50 })
export class ValidationSubscriber extends ApiRouteSubscriberBase<PostEntity> {}
// Low priority (executes last)
@ApiRouteSubscriber({ entity: PostEntity, priority: 10 })
export class LoggingSubscriber extends ApiRouteSubscriberBase<PostEntity> {}Next Steps
- Function Subscribers - Service-level hooks
- Execution Context - Context interfaces reference
- Lifecycle - Detailed execution flow