Case Studies
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), TypeScript 6 (6.0.x), Node.js 22 and 24 LTS, Express 5, TypeORM 1.1, Mongoose 9, graphql-ws 6 and the pre-1.0 @nestjs/locks package, and was checked on 2026-10-02. It is a set of three design walkthroughs, not an application to download: each one states the requirements, makes the main decisions in order, says which page of this section covers the piece that implements each decision, and shows one compact, runnable slice that carries the decision with the highest risk. The slices use the Nest 12 CLI project layout (ESM template, Vitest, supertest); their tests run against an in-memory SQLite database, an in-memory MongoDB server and two TCP microservices, and the output blocks are what those tests print.
The three scenarios are the ones a practical NestJS reference usually ends on, and they are deliberately different: a request/response API whose hard part is consistency, a real-time API whose hard part is fan-out, and a distributed system whose hard part is failure. The designs and code here are original and use current APIs. Where an older book or tutorial would lead you to an obsolete or unsafe approach, the section Where the book is outdated says so.
How to read a case study
Every walkthrough follows the same order, which is also a sensible order for a real project.
-
Pin down the invariants (what must never be wrong), because they decide where a transaction, a lock or an idempotency key is needed.
-
Choose the boundaries: modules, which data store owns which data, which operations are synchronous.
-
Put the cross-cutting rules in the framework, not in the handlers: authentication in a guard, validation in a pipe, error mapping in a filter.
-
Write the one slice where the invariant lives, and test the failure path as well as the happy path.
Case study 1: a REST e-commerce API
Requirements and decisions
Customers browse products and place orders; staff manage the catalogue. The invariants are that an order is never created for stock that does not exist, that the price a customer pays is the price the server computed (never one sent by the client), and that one customer cannot read another’s orders.
| Decision | Choice | Covered in |
|---|---|---|
Modules |
|
|
Persistence |
PostgreSQL through TypeORM; entities for |
|
Money |
Integer minor units (cents) in the database and in the API |
|
Input |
DTO classes with |
|
Authentication |
Short-lived JWT access tokens, verified in a guard; the signing key comes from |
|
Authorization |
Ownership check in the service (a foreign order is a |
|
Errors |
Domain failures become |
|
Output |
|
|
Duplicate submits |
|
|
Throttling and headers |
Rate limiting on login and checkout, security headers |
|
Contract |
OpenAPI generated from the DTOs |
|
Tests |
Unit tests for the service, an e2e test per route with the real validation and guard |
whitelist, nested DTOs] Pipe -->|400 if invalid| Client Pipe --> Ctl[OrdersController] Ctl --> Svc[OrdersService] Svc --> Tx{{Transaction}} Tx -->|price lines| P[(product)] Tx -->|conditional stock decrement| P Tx -->|save order and lines| O[(order, order_line)] Tx -->|no stock: 409, rollback| Client Svc -->|201 with the order| Client
The diagram is the request path in lifecycle order (guard before pipe before handler, see Request Lifecycle). The decision to remember is the transaction box: pricing, the stock decrement and the insert succeed or fail together.
The slice: validated order, guard and transaction
The entities keep money as integers and copy the unit price into the order line, so later catalogue changes cannot rewrite history.
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Product {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar', unique: true })
sku!: string;
@Column({ type: 'varchar' })
name!: string;
// Money is stored as integer minor units (cents), never as a float.
@Column({ type: 'integer' })
priceCents!: number;
@Column({ type: 'integer' })
stock!: number;
}
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'varchar' })
customerId!: string;
@Column({ type: 'varchar', default: 'placed' })
status!: 'placed' | 'paid' | 'cancelled';
@Column({ type: 'integer' })
totalCents!: number;
@OneToMany(() => OrderLine, (line) => line.order, { cascade: true })
lines!: OrderLine[];
}
@Entity()
export class OrderLine {
@PrimaryGeneratedColumn()
id!: number;
@ManyToOne(() => Order, (order) => order.lines, { onDelete: 'CASCADE' })
order!: Order;
@Column({ type: 'integer' })
productId!: number;
@Column({ type: 'integer' })
quantity!: number;
// Price captured at purchase time: later catalogue changes must not rewrite history.
@Column({ type: 'integer' })
unitPriceCents!: number;
}
The DTO is the whole input contract. The nested array needs @ValidateNested({ each: true }) together with @Type() from class-transformer; without @Type() the nested objects stay plain objects and are not validated.
import { Type } from 'class-transformer';
import { ArrayMaxSize, ArrayMinSize, IsArray, IsInt, Max, Min, ValidateNested } from 'class-validator';
export class OrderLineDto {
@IsInt()
@Min(1)
productId!: number;
@IsInt()
@Min(1)
@Max(100)
quantity!: number;
}
export class CreateOrderDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => OrderLineDto)
lines!: OrderLineDto[];
}
The guard verifies the bearer token with JwtService and publishes the user on the request. The signing key never appears in the source: the module reads it from ConfigService.
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, createParamDecorator } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
export interface AuthUser {
id: string;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string | undefined>; user?: AuthUser }>();
const [scheme, token] = (request.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) {
throw new UnauthorizedException();
}
try {
const payload = await this.jwt.verifyAsync<{ sub: string }>(token); // secret comes from JwtModule config
request.user = { id: payload.sub };
} catch {
throw new UnauthorizedException();
}
return true;
}
}
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): AuthUser => {
return context.switchToHttp().getRequest<{ user: AuthUser }>().user;
});
Source: NestJS documentation: Authentication, "Issue tokens to the mobile app". The current documentation builds sign-in on the pre-1.0 @nestjs/authentication package, whose JwtBearerProvider verifies bearer tokens and registers a global guard; the hand-written guard above uses only @nestjs/jwt, which is enough for a slice and works on every Nest version. Note that @nestjs/jwt counts lifetimes in seconds when given a number, so prefer a string such as '15m'.
The service is where the invariants live. The stock check is not a read followed by a write: it is a conditional UPDATE … WHERE stock >= :quantity, so two concurrent orders for the last unit cannot both succeed. If any line fails, the exception propagates out of DataSource.transaction() and everything, including the lines already decremented, rolls back.
import { ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In, MoreThanOrEqual } from 'typeorm';
import { CreateOrderDto } from './create-order.dto.js';
import { Order, OrderLine } from './order.entity.js';
import { Product } from './product.entity.js';
@Injectable()
export class OrdersService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
// One transaction: price the lines, take the stock, save the order. Any throw rolls everything back.
create(customerId: string, dto: CreateOrderDto): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
const products = await manager.find(Product, { where: { id: In(dto.lines.map((l) => l.productId)) } });
const byId = new Map(products.map((p) => [p.id, p]));
const lines: OrderLine[] = [];
let totalCents = 0;
for (const { productId, quantity } of dto.lines) {
const product = byId.get(productId);
if (!product) {
throw new UnprocessableEntityException(`Unknown product ${productId}`);
}
// Conditional decrement: it only matches while enough stock remains, so two concurrent
// orders can never both take the last unit (no read-then-write race).
const result = await manager.decrement(Product, { id: productId, stock: MoreThanOrEqual(quantity) }, 'stock', quantity);
if (!result.affected) {
throw new ConflictException(`Not enough stock for ${product.sku}`);
}
lines.push(manager.create(OrderLine, { productId, quantity, unitPriceCents: product.priceCents }));
totalCents += product.priceCents * quantity;
}
return manager.save(manager.create(Order, { customerId, totalCents, status: 'placed', lines }));
});
}
async findOwned(customerId: string, id: number): Promise<Order> {
// Another customer's order looks exactly like a missing one.
const order = await this.dataSource.getRepository(Order).findOne({ where: { id, customerId }, relations: { lines: true } });
if (!order) {
throw new NotFoundException();
}
return order;
}
}
The controller is thin: guard on the class, ParseIntPipe on the path parameter, the DTO on the body.
import { Body, Controller, Get, Param, ParseIntPipe, Post, UseGuards } from '@nestjs/common';
import { CreateOrderDto } from './create-order.dto.js';
import { type AuthUser, CurrentUser, JwtAuthGuard } from './jwt-auth.guard.js';
import { OrdersService } from './orders.service.js';
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateOrderDto) {
return this.orders.create(user.id, dto);
}
@Get(':id')
findOne(@CurrentUser() user: AuthUser, @Param('id', ParseIntPipe) id: number) {
return this.orders.findOwned(user.id, id);
}
}
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_PIPE } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtAuthGuard } from './jwt-auth.guard.js';
import { Order, OrderLine } from './order.entity.js';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { Product } from './product.entity.js';
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true }),
TypeOrmModule.forRoot({
type: 'better-sqlite3', // use 'postgres' in production
database: ':memory:',
entities: [Product, Order, OrderLine],
synchronize: true, // demo only: use migrations in production
}),
TypeOrmModule.forFeature([Product, Order, OrderLine]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ secret: config.getOrThrow<string>('JWT_SIGNING_KEY'), signOptions: { expiresIn: '15m' } }),
}),
],
controllers: [OrdersController],
providers: [
OrdersService,
JwtAuthGuard,
// Reject unknown properties and coerce the nested DTOs; every route gets it.
{ provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }) },
],
})
export class CommerceModule {}
Source: NestJS documentation: Validation (the ValidationPipe options, registered through APP_PIPE as described in Pipes, "Global scoped pipes") and Authentication.
An e2e test for the slice signs two tokens with the application’s own JwtService, so no credential is written anywhere. It prints:
NO TOKEN 401 {"message":"Unauthorized","statusCode":401}
INVALID {"message":["lines.0.property extra should not exist","lines.0.quantity must not be less than 1"],"error":"Bad Request","statusCode":400}
CREATED {"id":1,"customerId":"alice","status":"placed","totalCents":3900,"lines":[{"id":1,"productId":1,"quantity":2,"unitPriceCents":1500},{"id":2,"productId":2,"quantity":1,"unitPriceCents":900}]}
OVERSELL {"message":"Not enough stock for BOOK-1","error":"Conflict","statusCode":409}
After the 409, the stock of the first line (product 2) is back at its previous value, which is the rollback working. A request for an order with another customer’s token answers 404, exactly as for an order that does not exist.
What the slice leaves out, and where it lives
-
The slice uses SQLite in memory and
synchronize: trueto stay self-contained; use PostgreSQL with migrations as shown in Data Overview & TypeORM. -
Login, refresh tokens and password hashing are in Authentication; the pre-1.0
@nestjs/authenticationpackage is covered there too. -
Add
@Idempotent()on checkout and a payment call guarded by a timeout and a circuit breaker from@nestjs/resilience: Resilience, Idempotency & Locks. -
Confirmation e-mails and invoice PDFs are background work: publish an event or enqueue a job after the commit (Events, Scheduling & Queues); when losing that message is not acceptable, write it in the order’s transaction with the outbox (Outbox & Durable Workflows).
-
The catalogue endpoints are plain CRUD: Controllers & Routing, with caching from Caching and N+1 avoidance from Performance Optimization.
-
Deployment and the pipeline: Deployment and CI/CD.
Case study 2: a GraphQL social network
Requirements and decisions
Users publish short posts, follow each other, read a timeline and like posts, and see likes arrive live. The invariants are that a user’s like counts once however often the client retries, that only authenticated users can use the API, including over a WebSocket, and that a subscriber only receives events for the post it asked about.
| Decision | Choice | Covered in |
|---|---|---|
API style |
Code-first GraphQL on the Apollo driver; types generated from classes |
|
Persistence |
MongoDB through Mongoose; posts as documents with a compound index for timelines; counters updated with atomic operators |
|
Real time |
Subscriptions over the |
|
Fan-out |
In-memory |
|
Authentication |
One token reader used by the HTTP context and by the WebSocket |
|
N+1 |
A |
|
Abuse |
Query depth and complexity limits, throttling that understands GraphQL |
GraphQL: Subscriptions, Security & Plugins, Rate Limiting & Encryption |
Scale-out of the graph |
Federation when separate teams own parts of the schema |
|
Tests |
HTTP operations with supertest; subscriptions with a real |
For the language-neutral side (schema design, pagination, the N+1 problem), see the GraphQL guide.
The slice: atomic like and a filtered subscription
The schema stores who liked a post in a set and keeps a counter next to it; the compound index serves the timeline query.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import type { HydratedDocument } from 'mongoose';
@Schema({ timestamps: true })
export class Post {
@Prop({ required: true, index: true })
authorId!: string;
@Prop({ required: true, maxlength: 280 })
text!: string;
// Who liked the post: the set makes a second like by the same user a no-op.
@Prop({ type: [String], default: [] })
likedBy!: string[];
@Prop({ default: 0 })
likeCount!: number;
}
export type PostDocument = HydratedDocument<Post>;
export const PostSchema = SchemaFactory.createForClass(Post);
PostSchema.index({ authorId: 1, createdAt: -1 }); // timeline queries
The service performs the like as one update whose filter excludes users that already liked. A repeated like therefore matches nothing and returns null, and two simultaneous likes by the same user cannot both increment the counter.
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Post, type PostDocument } from './post.schema.js';
@Injectable()
export class PostsService {
constructor(@InjectModel(Post.name) private readonly posts: Model<Post>) {}
create(authorId: string, text: string): Promise<PostDocument> {
return this.posts.create({ authorId, text });
}
timeline(authorIds: string[], limit = 20): Promise<PostDocument[]> {
return this.posts.find({ authorId: { $in: authorIds } }).sort({ createdAt: -1 }).limit(limit).exec();
}
/** One atomic update: matches only if this user has not liked yet, so repeats and races count once. */
like(postId: string, userId: string): Promise<PostDocument | null> {
return this.posts
.findOneAndUpdate(
{ _id: postId, likedBy: { $ne: userId } },
{ $addToSet: { likedBy: userId }, $inc: { likeCount: 1 } },
{ returnDocument: 'after' },
)
.exec();
}
}
Source: NestJS documentation: MongoDB.
The GraphQL model and the token handling are small. TokenReader is used twice, by the HTTP context and by the WebSocket hook, so both transports authenticate identically.
import { Field, ID, InputType, Int, ObjectType } from '@nestjs/graphql';
import { MaxLength } from 'class-validator';
@ObjectType('Post')
export class PostModel {
@Field(() => ID)
id!: string;
@Field()
authorId!: string;
@Field()
text!: string;
@Field(() => Int)
likeCount!: number;
}
@InputType()
export class NewPostInput {
@Field()
@MaxLength(280)
text!: string;
}
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, createParamDecorator } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { GqlExecutionContext } from '@nestjs/graphql';
export interface SocialUser {
id: string;
}
/** Shared by the HTTP context factory and the graphql-ws onConnect hook. */
@Injectable()
export class TokenReader {
constructor(private readonly jwt: JwtService) {}
async read(authorization: string | undefined): Promise<SocialUser | undefined> {
const [scheme, token] = (authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) return undefined;
try {
return { id: (await this.jwt.verifyAsync<{ sub: string }>(token)).sub };
} catch {
return undefined;
}
}
}
@Injectable()
export class GqlAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (!GqlExecutionContext.create(context).getContext<{ user?: SocialUser }>().user) {
throw new UnauthorizedException();
}
return true;
}
}
export const CurrentUser = createParamDecorator((_data: unknown, context: ExecutionContext): SocialUser => {
return GqlExecutionContext.create(context).getContext<{ user: SocialUser }>().user;
});
Source: NestJS documentation: GraphQL resolvers and GraphQL, "Other features", "Execution context" (guards on resolvers use GqlExecutionContext).
The resolver publishes only when the like was new, and the subscription filter runs once per subscriber, so each client receives only the post it subscribed to.
import { Inject, UseGuards } from '@nestjs/common';
import { Args, ID, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { CurrentUser, GqlAuthGuard, type SocialUser } from './auth.js';
import { NewPostInput, PostModel } from './post.model.js';
import type { PostDocument } from './post.schema.js';
import { PostsService } from './posts.service.js';
export const PUB_SUB = 'PUB_SUB';
export const POST_LIKED = 'postLiked';
const toModel = (post: PostDocument): PostModel => ({
id: post.id as string,
authorId: post.authorId,
text: post.text,
likeCount: post.likeCount,
});
@Resolver(() => PostModel)
@UseGuards(GqlAuthGuard)
export class PostsResolver {
constructor(
private readonly posts: PostsService,
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
@Mutation(() => PostModel)
async createPost(@Args('input') input: NewPostInput, @CurrentUser() user: SocialUser) {
return toModel(await this.posts.create(user.id, input.text));
}
@Query(() => [PostModel])
async timeline(@Args('authors', { type: () => [String] }) authors: string[]) {
return (await this.posts.timeline(authors)).map(toModel);
}
@Mutation(() => PostModel, { nullable: true })
async likePost(@Args('postId', { type: () => ID }) postId: string, @CurrentUser() user: SocialUser) {
const post = await this.posts.like(postId, user.id);
if (!post) return null; // unknown post or already liked: nothing to announce
await this.pubSub.publish(POST_LIKED, { postLiked: toModel(post) });
return toModel(post);
}
// The payload key equals the field name, so no `resolve` is needed; `filter` runs per subscriber.
@Subscription(() => PostModel, {
filter: (payload: { postLiked: PostModel }, variables: { postId: string }) => payload.postLiked.id === variables.postId,
})
postLiked(@Args('postId', { type: () => ID }) _postId: string) {
return this.pubSub.asyncIterableIterator(POST_LIKED);
}
}
The module wires Mongoose, the token reader and the graphql-ws server. onConnect runs once per socket: returning false closes it with code 4403, and whatever is stored on ctx.extra is available to every operation on that socket through the GraphQL context function, which receives extra for WebSocket operations and req for HTTP ones.
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_PIPE } from '@nestjs/core';
import { GraphQLModule } from '@nestjs/graphql';
import { JwtModule } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import type { Context } from 'graphql-ws';
import { PubSub } from 'graphql-subscriptions';
import { GqlAuthGuard, TokenReader } from './auth.js';
import { Post, PostSchema } from './post.schema.js';
import { PostsResolver, PUB_SUB } from './posts.resolver.js';
import { PostsService } from './posts.service.js';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ secret: config.getOrThrow<string>('JWT_SIGNING_KEY') }),
}),
],
providers: [TokenReader],
exports: [TokenReader, JwtModule],
})
export class SocialAuthModule {}
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true }),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({ uri: config.getOrThrow<string>('MONGODB_URI') }),
}),
MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }]),
SocialAuthModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [SocialAuthModule],
inject: [TokenReader],
useFactory: (tokens: TokenReader) => ({
autoSchemaFile: true,
subscriptions: {
'graphql-ws': {
// Once per socket, before any subscribe message: authenticate here, not per event.
onConnect: async (ctx: Context) => {
const user = await tokens.read(ctx.connectionParams?.authorization as string | undefined);
if (!user) return false; // closes the socket with code 4403
(ctx.extra as { user?: unknown }).user = user;
},
},
},
// HTTP requests carry `req`; WebSocket operations carry `extra` (filled in onConnect).
context: async ({ req, extra }: { req?: { headers: Record<string, string> }; extra?: { user?: unknown } }) => ({
user: extra?.user ?? (await tokens.read(req?.headers.authorization)),
}),
}),
}),
],
providers: [
GqlAuthGuard,
PostsService,
PostsResolver,
{ provide: PUB_SUB, useValue: new PubSub() }, // single process; see the page for a Redis-backed PubSub
{ provide: APP_PIPE, useValue: new ValidationPipe({ whitelist: true, transform: true }) },
],
})
export class SocialModule {}
The slice’s test connects a real graphql-ws client. Bob likes another post, then the watched post twice, then Alice likes it; the watched subscription receives exactly two events:
SUB EVENTS [{"postLiked":{"id":"<postId>","likeCount":1}},{"postLiked":{"id":"<postId>","likeCount":2}}]
REPEAT {"likePost":null}
TIMELINE {"timeline":[{"text":"other","likeCount":1},{"text":"hello","likeCount":2}]}
CLOSE CODE 4403
An anonymous mutation answers UNAUTHENTICATED, a 300-character post is rejected by the validation pipe before reaching Mongoose, and a socket without a token is closed with 4403.
What the slice leaves out, and where it lives
-
The in-memory
PubSubdelivers only to subscribers connected to the same process. With several instances usegraphql-redis-subscriptions, as shown in GraphQL: Subscriptions, Security & Plugins. -
Followers and the timeline fan-out (write the post to followers' feeds, or read from the authors' posts and merge) are a data-model decision: the slice reads on demand with
$inand the compound index; a high-follower account needs fan-out on write, usually through a queue (Events, Scheduling & Queues). -
Notifications such as "Bob liked your post" over plain HTTP use Server-Sent Events; bidirectional chat uses WebSockets.
-
Media uploads for avatars and photos: File Upload & Streaming and i18n, Mail & File Storage.
-
Query cost control, persisted queries and plugins: GraphQL: Subscriptions, Security & Plugins.
Case study 3: an ERP built from microservices
Requirements and decisions
An ERP covers ordering, inventory, billing and later purchasing, HR and reporting. Each area has its own team and data. The invariants are that stock reserved for an order that was never paid is always released, that retrying any message never double-charges or double-reserves, and that two scheduled or manual runs of the same job never overlap.
| Decision | Choice | Covered in |
|---|---|---|
Boundaries |
One service per business capability, each with its own database; a gateway for HTTP |
|
Messaging |
Request/response commands over a transport (TCP here; RabbitMQ, Kafka or NATS in production); events on a broker |
|
Distributed transaction |
An orchestrated saga with compensating actions; durable state when it must survive a crash |
Microservices Architecture & Patterns, Outbox & Durable Workflows, Sagas |
Reliable events |
Transactional outbox: the event is written in the same transaction as the state change |
|
Idempotent handlers |
Every command can be applied twice with the same effect |
|
Background work |
BullMQ queues for reports, imports and e-mail; cron for periodic jobs |
|
Mutual exclusion |
Distributed locks with fencing tokens, per order and per scheduled job |
|
Failure isolation |
Timeouts, retries of safe calls only, circuit breakers and bulkheads between services |
|
Read models |
CQRS projections for reporting screens, fed by events |
|
Operations |
Health checks, structured logs, traces, graceful shutdown |
Health Checks & Observability, Logging, Lifecycle Events & Shutdown |
Tests |
Handler unit tests and transport-level tests with real sockets or a fake broker |
Orchestration (one component, here the gateway, tells each service what to do) is chosen over choreography (services react to each other’s events) because the order has one owner and one place to read its state; the trade-off, and when choreography wins, is in Microservices Architecture & Patterns.
The slice: orchestrated saga with compensation and a lock
Inventory and billing are ordinary controllers with @MessagePattern() handlers. A Map stands in for each service’s own database. The important property is idempotence: reserve and charge return success if the order was already handled, and release and refund do nothing if there is nothing to undo, so the orchestrator may repeat any of them after a timeout.
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload, RpcException } from '@nestjs/microservices';
/** Inventory service. In production it owns its own database; a Map stands in for it here. */
@Controller()
export class InventoryController {
readonly stock = new Map<string, number>([['PAPER-A4', 10]]);
readonly reservations = new Map<string, { sku: string; quantity: number }>();
// Handlers must be idempotent: the orchestrator may retry any command after a timeout.
@MessagePattern('inventory.reserve')
reserve(@Payload() cmd: { orderId: string; sku: string; quantity: number }) {
if (this.reservations.has(cmd.orderId)) return { reserved: true };
const available = this.stock.get(cmd.sku) ?? 0;
if (available < cmd.quantity) throw new RpcException('OUT_OF_STOCK');
this.stock.set(cmd.sku, available - cmd.quantity);
this.reservations.set(cmd.orderId, { sku: cmd.sku, quantity: cmd.quantity });
return { reserved: true };
}
// The compensation for reserve: releasing an unknown or already released order is a no-op.
@MessagePattern('inventory.release')
release(@Payload() cmd: { orderId: string }) {
const reservation = this.reservations.get(cmd.orderId);
if (reservation) {
this.stock.set(reservation.sku, (this.stock.get(reservation.sku) ?? 0) + reservation.quantity);
this.reservations.delete(cmd.orderId);
}
return { released: true };
}
}
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload, RpcException } from '@nestjs/microservices';
/** Billing service with a credit limit, so the saga has a step that can fail. */
@Controller()
export class BillingController {
readonly charges = new Map<string, number>();
@MessagePattern('billing.charge')
charge(@Payload() cmd: { orderId: string; amountCents: number }) {
if (this.charges.has(cmd.orderId)) return { charged: true };
if (cmd.amountCents > 100_000) throw new RpcException('CREDIT_LIMIT_EXCEEDED');
this.charges.set(cmd.orderId, cmd.amountCents);
return { charged: true };
}
@MessagePattern('billing.refund')
refund(@Payload() cmd: { orderId: string }) {
this.charges.delete(cmd.orderId);
return { refunded: true };
}
}
export const INVENTORY_CLIENT = Symbol('INVENTORY_CLIENT');
export const BILLING_CLIENT = Symbol('BILLING_CLIENT');
export interface OrderRequest {
orderId: string;
sku: string;
quantity: number;
amountCents: number;
}
Source: NestJS documentation: Microservices, "Request-response" and Microservices exception filters (RpcException).
The orchestrator runs the steps in order; when one fails it compensates the steps that completed, in reverse order. Each remote call has a timeout, and the whole run is wrapped in Locks.withLock() so only one run per order exists at a time on any instance. A compensation that itself fails is logged and must be handed to a retry queue or an operator: a saga that ignores that case silently leaves stock reserved.
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ClientProxy } from '@nestjs/microservices';
import { Locks } from '@nestjs/locks';
import { firstValueFrom, timeout } from 'rxjs';
import { BILLING_CLIENT, INVENTORY_CLIENT, type OrderRequest } from './contracts.js';
interface Step {
name: string;
invoke: () => Promise<unknown>;
compensate?: () => Promise<unknown>;
}
export class OrderSagaFailed extends Error {
constructor(
readonly failedStep: string,
readonly compensated: string[],
readonly reason: string,
) {
super(`Order saga failed at ${failedStep}: ${reason}`);
}
}
@Injectable()
export class OrderSaga {
private readonly logger = new Logger(OrderSaga.name);
constructor(
@Inject(INVENTORY_CLIENT) private readonly inventory: ClientProxy,
@Inject(BILLING_CLIENT) private readonly billing: ClientProxy,
private readonly locks: Locks,
) {}
private send(client: ClientProxy, pattern: string, data: unknown) {
return firstValueFrom(client.send(pattern, data).pipe(timeout(2000)));
}
/** One run per order at a time, on any instance: a duplicate request gets LockNotAcquiredError. */
place(order: OrderRequest): Promise<void> {
return this.locks.withLock(`erp:order:${order.orderId}`, () => this.run(order));
}
private async run(order: OrderRequest): Promise<void> {
const { orderId } = order;
const steps: Step[] = [
{
name: 'reserve-stock',
invoke: () => this.send(this.inventory, 'inventory.reserve', order),
compensate: () => this.send(this.inventory, 'inventory.release', { orderId }),
},
{
name: 'charge-customer',
invoke: () => this.send(this.billing, 'billing.charge', order),
compensate: () => this.send(this.billing, 'billing.refund', { orderId }),
},
];
const done: Step[] = [];
for (const step of steps) {
try {
await step.invoke();
done.push(step);
} catch (error) {
const compensated: string[] = [];
for (const completed of done.reverse()) {
try {
await completed.compensate?.();
compensated.push(completed.name);
} catch (compensationError) {
// A real system parks this on a retry queue or a dead-letter queue for an operator.
this.logger.error(`Compensation ${completed.name} failed for ${orderId}`, String(compensationError));
}
}
throw new OrderSagaFailed(step.name, compensated, String((error as { message?: string }).message ?? error));
}
}
}
}
Source: NestJS documentation: Distributed locks, "Run the export by hand" (Locks.withLock() and LockNotAcquiredError) and Microservices, "Client (producer class)" (ClientProxy).
The gateway module registers the TCP clients from configuration, installs the lock module, and maps the two failure kinds to HTTP: a duplicate run is a 409, a rolled-back saga is a 422 with the step that failed and what was compensated.
import { Body, ConflictException, Controller, HttpCode, Module, UnprocessableEntityException, Post } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { InMemoryLockStore, LockNotAcquiredError, LocksModule, LocksStorage } from '@nestjs/locks';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { BILLING_CLIENT, INVENTORY_CLIENT, type OrderRequest } from './contracts.js';
import { BillingController } from './billing.controller.js';
import { InventoryController } from './inventory.controller.js';
import { OrderSaga, OrderSagaFailed } from './order-saga.service.js';
@Controller('erp/orders')
class ErpOrdersController {
constructor(private readonly saga: OrderSaga) {}
@Post()
@HttpCode(201)
async place(@Body() order: OrderRequest) {
try {
await this.saga.place(order);
return { orderId: order.orderId, status: 'confirmed' };
} catch (error) {
if (error instanceof LockNotAcquiredError) throw new ConflictException('This order is already being processed');
if (error instanceof OrderSagaFailed) {
throw new UnprocessableEntityException({ status: 'rolled-back', failedStep: error.failedStep, compensated: error.compensated, reason: error.reason });
}
throw error;
}
}
}
const tcp = (name: symbol, prefix: string) => ({
imports: [ConfigModule],
name,
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: Transport.TCP as const,
options: { host: config.getOrThrow<string>(`${prefix}_HOST`), port: Number(config.getOrThrow<string>(`${prefix}_PORT`)) },
}),
});
/** The ERP gateway: HTTP in, saga orchestration out. */
@Module({
imports: [
ConfigModule.forRoot({ ignoreEnvFile: true }),
LocksModule.forRoot({ ttl: '30s' }),
ClientsModule.registerAsync([tcp(INVENTORY_CLIENT, 'INVENTORY'), tcp(BILLING_CLIENT, 'BILLING')]),
],
controllers: [ErpOrdersController],
providers: [
OrderSaga,
// Every gateway instance must share one lock store; production uses PostgreSQL or Redis.
{
provide: 'REGISTER_LOCK_STORE',
inject: [LocksStorage],
useFactory: (storage: LocksStorage) => storage.registerSource(new InMemoryLockStore()),
},
],
})
export class ErpGatewayModule {}
@Module({ controllers: [InventoryController] })
export class InventoryModule {}
@Module({ controllers: [BillingController] })
export class BillingModule {}
Source: NestJS documentation: Distributed locks (module and store registration) and Microservices, "Client (producer class)".
The slice’s test starts the two services as TCP microservices on local ports and the gateway as an HTTP application:
OK {"orderId":"o-1","status":"confirmed"} 8
ROLLED BACK {"status":"rolled-back","failedStep":"charge-customer","compensated":["reserve-stock"],"reason":"CREDIT_LIMIT_EXCEEDED"} 8
NO STOCK {"status":"rolled-back","failedStep":"reserve-stock","compensated":[],"reason":"OUT_OF_STOCK"}
DUPLICATE [201,409]
The trailing numbers are the stock of the item after the call: ten units, two sold, and the refused order’s three units put back. Two concurrent requests for the same order produce one 201 and one 409, and the stock is reduced once.
Making the slice production-grade
-
The saga must survive a crash of the orchestrator. The slice keeps no state: if the gateway dies after
reserveand beforecharge, nothing releases the stock. Persist the saga’s progress and resume it, which is what durable workflows are for (Outbox & Durable Workflows), or record the steps in a table and have a recovery job (under@OnOneInstance()) re-run unfinished ones. Because every handler is idempotent, resuming is safe. -
Events must not be lost. When a service changes its state and announces it, use the outbox so that both happen in one transaction, and make consumers idempotent with an inbox or a processed-message table.
-
Locks are leases, not guarantees. The lock here uses an in-memory store; every gateway instance must share a real store (PostgreSQL or Redis), and any write done under a lock should carry the fencing token so that a paused holder cannot overwrite a newer one.
-
Queues for the slow parts. Invoice generation, e-mail and bulk imports belong on BullMQ queues with retries, backoff and a failed-job policy, consumed by workers that can run as separate processes.
-
Resilience on every remote call. Replace the bare timeout with
@nestjs/resiliencepolicies: retries only for safe calls, a circuit breaker per dependency and a bulkhead around heavy work. -
Several transports. Replace TCP with RabbitMQ, NATS or Kafka as described in Microservice Transports, and run a hybrid application when one service also needs HTTP health endpoints.
Where the book is outdated
The reference book (see the section bibliography) was written before NestJS 12, and its three closing case studies lean on several patterns that should not be copied today.
-
The event emitter is not messaging between services.
@nestjs/event-emitter(EventEmitter2) is in-process: an event emitted in the orders service never reaches the billing service. Between services use a microservice transport, a broker, a queue, or the outbox. See Events, Scheduling & Queues. -
@nestjs/cqrssagas are not distributed sagas. A@Saga()is an in-memory RxJS stream inside one process, with no persistence, no retries and no compensation. A saga that spans services needs durable state, as in the slice above plus a persistent store, or@nestjs/workflows. See CQRS. -
A queue is not a circuit breaker. Putting calls behind a Bull queue spreads load and retries failed jobs, but it does not stop callers from hammering a failing dependency and it cannot give a synchronous answer. Use a breaker, timeout and bulkhead from
@nestjs/resiliencefor synchronous calls, and keep queues for work that can wait. Also prefer BullMQ (@nestjs/bullmq) over the legacy Bull package. -
Hand-written locks and idempotency. A Redlock-style lock written by hand has no fencing token, and a hand-written "already processed" table misses requests still in flight.
@nestjs/locks,@nestjs/idempotencyand@nestjs/resiliencenow cover these (all pre-1.0, so pin versions). -
Authentication. Passport strategies still work, but a guard around
JwtServiceis often enough, and the pre-1.0@nestjs/authenticationpackage exists. JWT secrets always come from configuration, andsynchronize: truein TypeORM is for demos only. -
GraphQL subscriptions. The old
subscriptions-transport-wsprotocol was removed; usegraphql-ws. GraphiQL is the default IDE in the current Apollo setup. -
Module format. Core packages are ESM-only in 12 and generated projects use
.jsimport extensions and Vitest by default.
For a dated list of every such change, see What’s Changed & Migration.
What changed recently
-
NestJS 12.0.0 (2026-08-27) made the core packages ESM-only and removed the old subscriptions protocol support; 12.1.0 (2026-09-23) added built-in cookies, CSRF protection and security headers, and an awaitable
commit()for@nestjs/cqrs. -
@nestjs/locks,@nestjs/idempotency,@nestjs/resilience,@nestjs/outbox,@nestjs/workflowsand@nestjs/authenticationare first-party and pre-1.0 as of 2026-10-02: expect API changes, and prefer the established alternative (a Redis lock library, BullMQ, Passport) where you cannot accept that. -
TypeORM is now 1.x (1.1.1 on 2026-10-02); the slice uses
DataSource.transaction()andmanager.decrement()with a conditionalwhere, as shown, on thebetter-sqlite3driver. -
The NestJS documentation was reorganised in September 2026; the links on this page use the new paths (
/data/,/security/,/reliability/*).
Testing the slices
Give each slice its own e2e spec and cover the decision it carries, not only the happy path. Twelve cases cover the three slices above: five for the e-commerce API (401, 400, 201, 409 with rollback, owner-only read), three for the social network (authentication, filtered subscription over a real graphql-ws client, 4403 close) and four for the ERP (success, compensation, failure at the first step, duplicate run). Keep npx tsc --noEmit and npm run build in the same pipeline, so a type error fails the build before the tests run.