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