Function Subscribers
Function subscribers intercept operations at the service level, immediately before and after repository method calls. They operate at the database layer, making them ideal for data transformation and enrichment.
Creating a Function Subscriber
Extend ApiFunctionSubscriberBase
and decorate with @ApiFunctionSubscriber
:
post-slug.subscriber.ts
import { Injectable } from "@nestjs/common";
import {
ApiFunctionSubscriber,
ApiFunctionSubscriberBase,
IApiSubscriberFunctionExecutionContext,
TApiFunctionCreateProperties
} from "@elsikora/nestjs-crud-automator";
import { PostEntity } from "./post.entity";
@Injectable()
@ApiFunctionSubscriber({
entity: PostEntity,
priority: 10, // Higher priority executes first
})
export class PostSlugSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
// Implement hook methods
}
Available Hooks
Before Hooks
Called before repository operations:
onBeforeCreate
async onBeforeCreate(
context: IApiSubscriberFunctionExecutionContext<
PostEntity,
TApiFunctionCreateProperties<PostEntity>
>
): Promise<TApiFunctionCreateProperties<PostEntity>> {
const { body } = context.result;
// Generate slug from title
if (body.title && !body.slug) {
body.slug = this.generateSlug(body.title);
}
// Set defaults
body.status = body.status || "draft";
body.viewCount = 0;
return context.result;
}
private generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
}
onBeforeUpdate
async onBeforeUpdate(
context: IApiSubscriberFunctionExecutionContext<
PostEntity,
TApiFunctionCreateProperties<PostEntity>
>
): Promise<TApiFunctionCreateProperties<PostEntity>> {
const { body } = context.result;
// Update slug if title changed
if (body.title) {
body.slug = this.generateSlug(body.title);
}
// Track modification count
body.modificationCount = (context.ENTITY.modificationCount || 0) + 1;
return context.result;
}
onBeforeGet
, onBeforeGetList
, onBeforeGetMany
, onBeforeDelete
async onBeforeGet(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
// Increment view counter before fetching
await this.repository.increment(
{ id: context.ENTITY.id },
"viewCount",
1
);
return context.result;
}
async onBeforeDelete(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
// Archive before deletion
await this.archiveService.archive(context.ENTITY);
return context.result;
}
After Hooks
Called after repository operations:
onAfterCreate
async onAfterCreate(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
const post = context.result;
// Create related entities
if (post.tags) {
await this.createTags(post.id, post.tags);
}
// Update counters
await this.updateAuthorPostCount(post.authorId);
return post;
}
onAfterUpdate
async onAfterUpdate(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
const post = context.result;
// Update search index
await this.searchService.updateIndex(post);
// Regenerate sitemap
await this.sitemapService.regenerate();
return post;
}
onAfterGet
, onAfterGetList
, onAfterGetMany
, onAfterDelete
async onAfterGetList(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity[]>
): Promise<PostEntity[]> {
const posts = context.result;
// Enrich with computed data
for (const post of posts) {
post.readingTime = this.calculateReadingTime(post.content);
}
return posts;
}
private calculateReadingTime(content: string): number {
const words = content.split(/\s+/).length;
return Math.ceil(words / 200); // 200 words per minute
}
Error Hooks
Called when repository operations fail:
async onBeforeErrorCreate(
context: IApiSubscriberFunctionErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> {
// Log before error propagates
console.error("Post creation about to fail:", {
error: error.message,
entity: context.ENTITY,
});
}
async onAfterErrorCreate(
context: IApiSubscriberFunctionErrorExecutionContext<PostEntity>,
error: Error
): Promise<void> {
// Cleanup after error
if (context.ENTITY.tempFiles) {
await this.cleanupTempFiles(context.ENTITY.tempFiles);
}
}
// Similar hooks for other operations
async onBeforeErrorUpdate(...) {}
async onAfterErrorUpdate(...) {}
async onBeforeErrorGet(...) {}
async onAfterErrorGet(...) {}
async onBeforeErrorGetList(...) {}
async onAfterErrorGetList(...) {}
async onBeforeErrorGetMany(...) {}
async onAfterErrorGetMany(...) {}
async onBeforeErrorDelete(...) {}
async onAfterErrorDelete(...) {}
Practical Examples
Automatic Slug Generation
slug-generator.subscriber.ts
import slugify from "slugify";
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity })
export class SlugGeneratorSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
async onBeforeCreate(
context: IApiSubscriberFunctionExecutionContext<
PostEntity,
TApiFunctionCreateProperties<PostEntity>
>
): Promise<TApiFunctionCreateProperties<PostEntity>> {
const { body } = context.result;
if (body.title && !body.slug) {
body.slug = slugify(body.title, {
lower: true,
strict: true,
trim: true,
});
// Ensure uniqueness
body.slug = await this.ensureUniqueSlug(body.slug);
}
return context.result;
}
private async ensureUniqueSlug(baseSlug: string): Promise<string> {
let slug = baseSlug;
let counter = 1;
while (await this.slugExists(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}
return slug;
}
private async slugExists(slug: string): Promise<boolean> {
const count = await this.repository.count({ where: { slug } });
return count > 0;
}
}
Full-Text Search Indexing
search-index.subscriber.ts
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity })
export class SearchIndexSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
constructor(
@InjectRepository(PostEntity)
public repository: Repository<PostEntity>,
private readonly searchService: SearchService
) {
super();
}
async onAfterCreate(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
await this.searchService.indexDocument({
id: context.result.id,
title: context.result.title,
content: context.result.content,
tags: context.result.tags,
});
return context.result;
}
async onAfterUpdate(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
await this.searchService.updateDocument(context.result.id, {
title: context.result.title,
content: context.result.content,
tags: context.result.tags,
});
return context.result;
}
async onAfterDelete(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
await this.searchService.deleteDocument(context.result.id);
return context.result;
}
}
Computed Fields
computed-fields.subscriber.ts
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity })
export class ComputedFieldsSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
async onAfterGet(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
const post = context.result;
// Calculate reading time
post.readingTimeMinutes = this.calculateReadingTime(post.content);
// Calculate content summary
post.summary = this.generateSummary(post.content, 200);
return post;
}
async onAfterGetList(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity[]>
): Promise<PostEntity[]> {
for (const post of context.result) {
post.readingTimeMinutes = this.calculateReadingTime(post.content);
post.summary = this.generateSummary(post.content, 150);
}
return context.result;
}
private calculateReadingTime(content: string): number {
const words = content.split(/\s+/).length;
return Math.ceil(words / 200);
}
private generateSummary(content: string, maxLength: number): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength).trim() + "...";
}
}
Cascade Operations
cascade-operations.subscriber.ts
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity })
export class CascadeOperationsSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
constructor(
@InjectRepository(PostEntity)
public repository: Repository<PostEntity>,
@InjectRepository(CommentEntity)
private commentRepository: Repository<CommentEntity>,
@InjectRepository(LikeEntity)
private likeRepository: Repository<LikeEntity>
) {
super();
}
async onBeforeDelete(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
const postId = context.ENTITY.id;
// Delete related comments
await this.commentRepository.delete({ postId });
// Delete related likes
await this.likeRepository.delete({ postId });
return context.result;
}
}
Versioning and History
version-history.subscriber.ts
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity })
export class VersionHistorySubscriber extends ApiFunctionSubscriberBase<PostEntity> {
constructor(
@InjectRepository(PostEntity)
public repository: Repository<PostEntity>,
@InjectRepository(PostHistoryEntity)
private historyRepository: Repository<PostHistoryEntity>
) {
super();
}
async onAfterUpdate(
context: IApiSubscriberFunctionExecutionContext<PostEntity, PostEntity>
): Promise<PostEntity> {
const post = context.result;
// Save version to history
await this.historyRepository.save({
postId: post.id,
title: post.title,
content: post.content,
version: post.version,
createdAt: new Date(),
});
// Increment version
await this.repository.update(
{ id: post.id },
{ version: post.version + 1 }
);
return post;
}
}
Data Validation
data-validation.subscriber.ts
@Injectable()
@ApiFunctionSubscriber({ entity: PostEntity, priority: 100 })
export class DataValidationSubscriber extends ApiFunctionSubscriberBase<PostEntity> {
async onBeforeCreate(
context: IApiSubscriberFunctionExecutionContext<
PostEntity,
TApiFunctionCreateProperties<PostEntity>
>
): Promise<TApiFunctionCreateProperties<PostEntity>> {
const { body } = context.result;
// Validate content length
if (body.content && body.content.length < 100) {
throw new BadRequestException("Post content must be at least 100 characters");
}
// Validate image URLs
if (body.images) {
for (const imageUrl of body.images) {
if (!await this.isValidImageUrl(imageUrl)) {
throw new BadRequestException(`Invalid image URL: ${imageUrl}`);
}
}
}
return context.result;
}
private async isValidImageUrl(url: string): Promise<boolean> {
try {
const response = await fetch(url, { method: "HEAD" });
const contentType = response.headers.get("content-type");
return contentType?.startsWith("image/") || false;
} catch {
return false;
}
}
}
Priority Ordering
Function subscribers execute in priority order (high to low for before hooks, low to high for after hooks):
// Validation (highest priority - executes first)
@ApiFunctionSubscriber({ entity: PostEntity, priority: 100 })
export class ValidationSubscriber {}
// Data enrichment
@ApiFunctionSubscriber({ entity: PostEntity, priority: 50 })
export class EnrichmentSubscriber {}
// Logging (lowest priority - executes last)
@ApiFunctionSubscriber({ entity: PostEntity, priority: 10 })
export class LoggingSubscriber {}
Next Steps
- Route Subscribers - Controller-level hooks
- Execution Context - Context interfaces reference
- Lifecycle - Detailed execution flow
Last updated on