Skip to Content
Engineering11 Documentation šŸ”„
BackendMicroservice Implementation

Engineering11 Custom Microservice Implementation Guide

Overview

This guide demonstrates how to build a custom microservice using the Foundation’s patterns and framework. Using a fictional ā€œProduct Reviewsā€ service as an example, we’ll show the core patterns including REST APIs, CQRS tasks, repositories, and event-driven architecture.

Service Structure

A microservice is composed of optional parts based on your needs:

service-product-reviews/ ā”œā”€ā”€ api/ # Shared business logic (SDK-style) [OPTIONAL] │ └── src/ │ ā”œā”€ā”€ constants.ts # Service constants (collections, events, permissions) │ ā”œā”€ā”€ models/ # Domain models and interfaces │ ā”œā”€ā”€ repositories/ # Data access layer │ └── services/ # Business logic services │ ā”œā”€ā”€ rest/ # HTTP REST API endpoints [OPTIONAL] │ └── src/ │ ā”œā”€ā”€ main.ts # Bootstrap REST service │ ā”œā”€ā”€ app.module.ts # Root module │ ā”œā”€ā”€ rest/ # Feature controllers │ └── models/ # DTOs and request/response models │ ā”œā”€ā”€ tasks/ # CQRS event-driven processing [OPTIONAL] │ └── src/ │ ā”œā”€ā”€ main.ts # Bootstrap tasks service │ ā”œā”€ā”€ app.module.ts # Root module with CQRS │ └── tasks/ # Feature modules │ └── reviews/ │ ā”œā”€ā”€ reviews.controller.ts # Event receivers │ ā”œā”€ā”€ reviews.sagas.ts # Event handlers │ └── commands/ │ ā”œā”€ā”€ impl/ # Command definitions │ └── handlers/ # Command handlers │ ā”œā”€ā”€ functions/ # Cloud Functions for event triggers [OPTIONAL] │ └── src/ │ └── index.ts # Function exports │ ā”œā”€ā”€ jobs/ # Scheduled/recurring jobs [OPTIONAL] │ └── src/ │ └── main.ts # Job definitions │ ā”œā”€ā”€ migrations/ # Schema/data migrations [OPTIONAL] │ └── scripts/ │ └── shared/ # Shared code across full stack [OPTIONAL] └── src/

Choose only the parts you need - a simple service might only have api and rest.


High-Level Architecture


1. API Part (Shared Business Logic)

Constants (api/src/constants.ts)

// Firestore collections export const COLLECTIONS = { REVIEWS: "reviews", RATINGS: "ratings", REVIEW_STATS: "reviewStats", } as const; // PubSub topics export const TOPICS = { REVIEW_CREATED: "product-reviews.review.created", REVIEW_UPDATED: "product-reviews.review.updated", REVIEW_DELETED: "product-reviews.review.deleted", REVIEW_APPROVED: "product-reviews.review.approved", RATING_CALCULATED: "product-reviews.rating.calculated", } as const; // Task endpoints export const ENDPOINTS = { REVIEW_CREATED: "/review/created", REVIEW_UPDATED: "/review/updated", REVIEW_DELETED: "/review/deleted", } as const; // Permissions export enum ReviewPermissions { CreateReview = "reviews:create", UpdateOwnReview = "reviews:update:own", UpdateAnyReview = "reviews:update:any", DeleteReview = "reviews:delete", ApproveReview = "reviews:approve", ViewHiddenReviews = "reviews:view:hidden", }

Domain Models (api/src/models/review.model.ts)

export enum ReviewStatus { Pending = "pending", Approved = "approved", Rejected = "rejected", Flagged = "flagged", } export interface IReview { id: string; productId: string; authorId: string; customerKey: string; rating: number; title: string; content: string; status: ReviewStatus; isVerifiedPurchase: boolean; helpfulCount: number; createdAt: FirebaseFirestore.Timestamp; updatedAt: FirebaseFirestore.Timestamp; } export interface IReviewStats { id: string; // productId averageRating: number; totalReviews: number; ratingDistribution: { 1: number; 2: number; 3: number; 4: number; 5: number; }; }

Repository Layer (api/src/repositories/review.repository.ts)

import { CollectionRepository, FirestoreQueryBuilder, OrderByDirection, } from "@engineering11/datastores"; import { IReview, ReviewStatus, COLLECTIONS } from "../models"; export class ReviewRepository extends CollectionRepository<IReview> { protected COLLECTION = COLLECTIONS.REVIEWS; // Custom query using FirestoreQueryBuilder async getApprovedReviewsForProduct( productId: string, limit: number = 10, ): Promise<IReview[]> { const queryBuilder = new FirestoreQueryBuilder() .setPath(this.COLLECTION) .addCriteria("productId", "==", productId) .addCriteria("status", "==", ReviewStatus.Approved) .setOrderBy("helpfulCount", OrderByDirection.Desc) .setLimit(limit); return super._query(queryBuilder.build()); } }

Available Base Class Methods from CollectionRepository<T>:

// CRUD Operations get(id: string, transaction?: Transaction): Promise<T | undefined> getOrError(id: string, transaction?: Transaction): Promise<T> // Throws if not found create(model: T, transaction?: Transaction): Promise<void> update(model: Partial<T> & {id: string}, transaction?: Transaction): Promise<void> delete(id: string, transaction?: Transaction): Promise<void> // Batch Operations batchCreate(models: T[]): Promise<void> batchUpdate(models: Array<Partial<T> & {id: string}>): Promise<void> batchDelete(ids: string[]): Promise<void> // Querying query(criteria: QueryCriteria): Promise<T[]> queryCollection(builder: TypedFirestoreQueryBuilder<T>): Promise<T[]> // Transactions runTransaction<R>(callback: (transaction: Transaction) => Promise<R>): Promise<R> // Utilities generateId(): string // Generate new document ID exists(id: string): Promise<boolean>

SubcollectionRepository Example:

import { SubcollectionRepository } from "@engineering11/datastores"; // Define path builder for subcollection export const reviewCommentPath = (reviewId: string, commentId: string) => `${COLLECTIONS.REVIEWS}/${reviewId}/comments/${commentId}`; export class ReviewCommentRepository extends SubcollectionRepository< IReviewComment, typeof reviewCommentPath > { protected buildPath = reviewCommentPath; // Override to handle parent/child IDs async create( comment: IReviewComment, transaction?: Transaction, ): Promise<void> { return super.create(comment, comment.reviewId, comment.id, transaction); } async getByReviewId(reviewId: string): Promise<IReviewComment[]> { const queryBuilder = new FirestoreQueryBuilder() .setPath(`${COLLECTIONS.REVIEWS}/${reviewId}/comments`) .setOrderBy("createdAt", OrderByDirection.Desc); return super._query(queryBuilder.build()); } }

2. REST Part

Bootstrap (rest/src/main.ts)

import { bootstrapRest } from "@engineering11/rest"; import { AppModule } from "./app.module"; bootstrapRest(AppModule, { serviceName: "product-reviews" });

Root Module (rest/src/app.module.ts)

import { Module } from "@nestjs/common"; import { AppController } from "./app.controller"; import { ReviewsModule } from "./rest/reviews/reviews.module"; import { RatingsModule } from "./rest/ratings/ratings.module"; @Module({ imports: [ReviewsModule, RatingsModule], controllers: [AppController], providers: [], }) export class AppModule {}

DTOs with Validation (rest/src/models/review.dto.ts)

import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { IsString, IsNotEmpty, IsNumber, Min, Max, IsBoolean, IsOptional, IsEnum, } from "class-validator"; import { ReviewStatus } from "@engineering11/product-reviews-api"; export class CreateReviewDTO { @ApiProperty({ description: "Product ID to review" }) @IsString() @IsNotEmpty() productId: string; @ApiProperty({ description: "Rating from 1-5", minimum: 1, maximum: 5 }) @IsNumber() @Min(1) @Max(5) rating: number; @ApiProperty({ description: "Review title" }) @IsString() @IsNotEmpty() title: string; @ApiProperty({ description: "Review content" }) @IsString() @IsNotEmpty() content: string; @ApiPropertyOptional({ description: "Is this a verified purchase?" }) @IsOptional() @IsBoolean() isVerifiedPurchase?: boolean; } export class UpdateReviewDTO { @ApiPropertyOptional() @IsOptional() @IsNumber() @Min(1) @Max(5) rating?: number; @ApiPropertyOptional() @IsOptional() @IsString() title?: string; @ApiPropertyOptional() @IsOptional() @IsString() content?: string; } export class UpdateReviewStatusDTO { @ApiProperty({ enum: ReviewStatus }) @IsEnum(ReviewStatus) status: ReviewStatus; }

Controller with Decorators (rest/src/rest/reviews/reviews.controller.ts)

import { Controller, Get, Post, Put, Delete, Body, Param, Query, HttpStatus, } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiOkResponse, ApiCreatedResponse, ApiNotFoundResponse, ApiForbiddenResponse, } from "@nestjs/swagger"; import { External, HasPermission, Claim, UserId, E11ValidationPipe, apiError, } from "@engineering11/rest"; import { ReviewService, ReviewPermissions, IReview, } from "@engineering11/product-reviews-api"; import { CreateReviewDTO, UpdateReviewDTO, UpdateReviewStatusDTO, } from "../../models/review.dto"; @External() // Requires authentication @ApiTags("Reviews") @Controller({ path: "reviews", version: "1" }) export class ReviewsController { constructor(private reviewService: ReviewService) {} @ApiOperation({ summary: "Create a new review" }) @ApiCreatedResponse({ description: "Review created successfully" }) @HasPermission(ReviewPermissions.CreateReview) @Post() async createReview( @Body( new E11ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }), ) dto: CreateReviewDTO, @UserId() userId: string, // Extract appUserId from JWT @Claim("customerKey") customerKey: string, // Extract customerKey claim ): Promise<IReview> { return this.reviewService.create({ ...dto, authorId: userId, customerKey, }); } @ApiOperation({ summary: "Get reviews for a product" }) @ApiOkResponse({ description: "List of reviews" }) @Get("product/:productId") async getProductReviews( @Param("productId") productId: string, @Query("limit") limit?: number, ): Promise<IReview[]> { return this.reviewService.getApprovedReviewsForProduct(productId, limit); } @ApiOperation({ summary: "Get current user reviews" }) @ApiOkResponse({ description: "User reviews" }) @Get("my-reviews") async getMyReviews(@UserId() userId: string): Promise<IReview[]> { return this.reviewService.getUserReviews(userId); } @ApiOperation({ summary: "Get a specific review" }) @ApiOkResponse({ description: "Review details" }) @ApiNotFoundResponse({ description: "Review not found" }) @Get(":id") async getReview(@Param("id") id: string): Promise<IReview> { const review = await this.reviewService.get(id); if (!review) { throw apiError({ title: "Review not found", type: "product-reviews/review-not-found", statusCode: HttpStatus.NOT_FOUND, }); } return review; } @ApiOperation({ summary: "Update own review" }) @ApiOkResponse({ description: "Review updated" }) @ApiForbiddenResponse({ description: "Cannot update another user review" }) @HasPermission(ReviewPermissions.UpdateOwnReview) @Put(":id") async updateReview( @Param("id") id: string, @Body( new E11ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }), ) dto: UpdateReviewDTO, @UserId() userId: string, ): Promise<void> { const review = await this.reviewService.getOrError(id); if (review.authorId !== userId) { throw apiError({ title: "Forbidden", type: "product-reviews/forbidden", statusCode: HttpStatus.FORBIDDEN, message: "You can only update your own reviews", }); } await this.reviewService.update(id, dto); } @ApiOperation({ summary: "Update review status (admin only)" }) @ApiOkResponse({ description: "Review status updated" }) @HasPermission(ReviewPermissions.ApproveReview) @Put(":id/status") async updateReviewStatus( @Param("id") id: string, @Body(new E11ValidationPipe({ whitelist: true })) dto: UpdateReviewStatusDTO, ): Promise<void> { await this.reviewService.update(id, { status: dto.status }); } @ApiOperation({ summary: "Delete a review" }) @ApiOkResponse({ description: "Review deleted" }) @HasPermission(ReviewPermissions.DeleteReview) @Delete(":id") async deleteReview(@Param("id") id: string): Promise<void> { await this.reviewService.delete(id); } }

Feature Module (rest/src/rest/reviews/reviews.module.ts)

import { Module } from "@nestjs/common"; import { injectable } from "@engineering11/server-utils"; import { ReviewService } from "@engineering11/product-reviews-api"; import { ReviewsController } from "./reviews.controller"; @Module({ controllers: [ReviewsController], providers: [ injectable(ReviewService), // Singleton service ], }) export class ReviewsModule {}

3. Tasks Part (CQRS Architecture)

Bootstrap (tasks/src/main.ts)

import { bootstrapTasks } from "@engineering11/rest"; import { AppModule } from "./app.module"; bootstrapTasks(AppModule, "Product Reviews Tasks");

Root Module (tasks/src/app.module.ts)

import { Module } from "@nestjs/common"; import { CqrsModule } from "@nestjs/cqrs"; import { ReviewTasksModule } from "./tasks/reviews/reviews.module"; import { RatingTasksModule } from "./tasks/ratings/ratings.module"; @Module({ imports: [ CqrsModule, // Enable CQRS ReviewTasksModule, RatingTasksModule, ], }) export class AppModule {}

Event Definitions (tasks/src/tasks/reviews/events/index.ts)

import { TaskPayload } from "@engineering11/rest"; import { IReview } from "@engineering11/product-reviews-api"; export class ReviewCreatedEvent { constructor(public payload: TaskPayload<IReview>) {} } export class ReviewUpdatedEvent { constructor(public payload: TaskPayload<IReview>) {} } export class ReviewDeletedEvent { constructor(public payload: TaskPayload<IReview>) {} } export class ReviewApprovedEvent { constructor(public payload: TaskPayload<IReview>) {} }

Command Definitions (tasks/src/tasks/reviews/commands/impl/)

// update-rating-stats.command.ts export class UpdateRatingStatsCommand { constructor( public readonly productId: string, public readonly rating: number, ) {} } // send-notification.command.ts export class SendReviewNotificationCommand { constructor( public readonly review: IReview, public readonly notificationType: "created" | "approved" | "rejected", ) {} }

Command Handlers (tasks/src/tasks/reviews/commands/handlers/)

// update-rating-stats.handler.ts import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; import { UpdateRatingStatsCommand } from "../impl/update-rating-stats.command"; import { RatingStatsService } from "@engineering11/product-reviews-api"; @CommandHandler(UpdateRatingStatsCommand) export class UpdateRatingStatsHandler implements ICommandHandler< UpdateRatingStatsCommand, void > { constructor(private ratingStatsService: RatingStatsService) {} async execute(command: UpdateRatingStatsCommand): Promise<void> { const { productId, rating } = command; await this.ratingStatsService.updateStats(productId, rating); } } // send-notification.handler.ts import { CommandHandler, ICommandHandler } from "@nestjs/cqrs"; import { SendReviewNotificationCommand } from "../impl/send-notification.command"; import { NotificationService } from "@engineering11/notifications-api"; @CommandHandler(SendReviewNotificationCommand) export class SendReviewNotificationHandler implements ICommandHandler< SendReviewNotificationCommand, void > { constructor(private notificationService: NotificationService) {} async execute(command: SendReviewNotificationCommand): Promise<void> { const { review, notificationType } = command; await this.notificationService.send({ userId: review.authorId, type: `review-${notificationType}`, data: { reviewId: review.id }, }); } } // index.ts export * from "./update-rating-stats.handler"; export * from "./send-notification.handler"; export const COMMAND_HANDLERS = [ UpdateRatingStatsHandler, SendReviewNotificationHandler, ];

Sagas (Event to Command Mappers) (tasks/src/tasks/reviews/reviews.sagas.ts)

import { Injectable } from "@nestjs/common"; import { Saga, ICommand } from "@nestjs/cqrs"; import { Observable } from "rxjs"; import { map, filter, mergeMap } from "rxjs/operators"; import { isOfType, hasChangedAny } from "@engineering11/cqrs-utils"; import { ReviewCreatedEvent, ReviewUpdatedEvent, ReviewApprovedEvent, } from "./events"; import { UpdateRatingStatsCommand, SendReviewNotificationCommand, } from "./commands/impl"; @Injectable() export class ReviewSagas { @Saga() reviewCreated = (events$: Observable<any>): Observable<ICommand> => events$.pipe( isOfType(ReviewCreatedEvent), mergeMap(({ payload }) => [ // Update product rating stats new UpdateRatingStatsCommand( payload.after.productId, payload.after.rating, ), // Send notification to user new SendReviewNotificationCommand(payload.after, "created"), ]), ); @Saga() reviewRatingUpdated = (events$: Observable<any>): Observable<ICommand> => events$.pipe( isOfType(ReviewUpdatedEvent), filter(({ payload }) => hasChangedAny(["rating"], payload)), map( ({ payload }) => new UpdateRatingStatsCommand( payload.after.productId, payload.after.rating, ), ), ); @Saga() reviewApproved = (events$: Observable<any>): Observable<ICommand> => events$.pipe( isOfType(ReviewApprovedEvent), map( ({ payload }) => new SendReviewNotificationCommand(payload.after, "approved"), ), ); }

Task Controllers (Event Receivers) (tasks/src/tasks/reviews/reviews.controller.ts)

import { Controller, Post, Body } from "@nestjs/common"; import { EventBus } from "@nestjs/cqrs"; import { TaskPayload } from "@engineering11/rest"; import { IReview, ENDPOINTS } from "@engineering11/product-reviews-api"; import { ReviewCreatedEvent, ReviewUpdatedEvent, ReviewDeletedEvent, ReviewApprovedEvent, } from "./events"; @Controller() export class ReviewTasksController { constructor(private eventBus: EventBus) {} @Post(ENDPOINTS.REVIEW_CREATED) async reviewCreated(@Body() payload: TaskPayload<IReview>) { return this.eventBus.publish(new ReviewCreatedEvent(payload)); } @Post(ENDPOINTS.REVIEW_UPDATED) async reviewUpdated(@Body() payload: TaskPayload<IReview>) { return this.eventBus.publish(new ReviewUpdatedEvent(payload)); } @Post(ENDPOINTS.REVIEW_DELETED) async reviewDeleted(@Body() payload: TaskPayload<IReview>) { return this.eventBus.publish(new ReviewDeletedEvent(payload)); } @Post(ENDPOINTS.REVIEW_APPROVED) async reviewApproved(@Body() payload: TaskPayload<IReview>) { return this.eventBus.publish(new ReviewApprovedEvent(payload)); } }

Feature Module (tasks/src/tasks/reviews/reviews.module.ts)

import { Module } from "@nestjs/common"; import { CqrsModule } from "@nestjs/cqrs"; import { injectable } from "@engineering11/server-utils"; import { ReviewService, RatingStatsService, } from "@engineering11/product-reviews-api"; import { NotificationService } from "@engineering11/notifications-api"; import { ReviewTasksController } from "./reviews.controller"; import { ReviewSagas } from "./reviews.sagas"; import { COMMAND_HANDLERS } from "./commands/handlers"; @Module({ imports: [CqrsModule], controllers: [ReviewTasksController], providers: [ // Services injectable(ReviewService), injectable(RatingStatsService), injectable(NotificationService), // CQRS ReviewSagas, ...COMMAND_HANDLERS, ], }) export class ReviewTasksModule {}

Summary

This architecture enables event-driven microservices with clear separation of concerns across optional service parts:

  • API Part: Shared business logic and repositories
  • REST Part: Client-facing HTTP endpoints
  • Tasks Part: Async event processing with CQRS
  • Functions: Event-triggered cloud functions
  • Jobs: Scheduled background work
  • Migrations: Database evolution scripts

Each part is optional — build only what your service needs.

Last updated on