Skip to Content

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

Last updated on