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.

  1. Pin down the invariants (what must never be wrong), because they decide where a transaction, a lock or an idempotency key is needed.

  2. Choose the boundaries: modules, which data store owns which data, which operations are synchronous.

  3. 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.

  4. 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

CatalogModule, OrdersModule, AuthModule, a shared DatabaseModule; each feature module exports only its service

Modules, Project Structure & Workspaces

Persistence

PostgreSQL through TypeORM; entities for Product, Order, OrderLine; migrations in production, never synchronize

Data Overview & TypeORM

Money

Integer minor units (cents) in the database and in the API

Data Overview & TypeORM

Input

DTO classes with class-validator, one global ValidationPipe with whitelist and forbidNonWhitelisted

Pipes & Validation

Authentication

Short-lived JWT access tokens, verified in a guard; the signing key comes from ConfigService

Authentication, Configuration

Authorization

Ownership check in the service (a foreign order is a 404); roles or policies for staff routes

Authorization, Guards

Errors

Domain failures become HttpException subclasses; unexpected ones go to a filter that hides internals

Exception Filters & Error Handling

Output

ClassSerializerInterceptor or explicit response DTOs so entities never leak columns

Serialization

Duplicate submits

Idempotency-Key on POST /orders

Resilience, Idempotency & Locks

Throttling and headers

Rate limiting on login and checkout, security headers

Rate Limiting & Encryption, Security Headers, CORS & CSRF

Contract

OpenAPI generated from the DTOs

OpenAPI & Swagger

Tests

Unit tests for the service, an e2e test per route with the real validation and guard

Testing

flowchart LR Client([Client]) -->|POST /orders + Bearer token| Guard[JwtAuthGuard] Guard -->|401 if invalid| Client Guard --> Pipe[ValidationPipe
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.

product.entity.ts
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;
}
order.entity.ts
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.

create-order.dto.ts
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.

jwt-auth.guard.ts
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.

orders.service.ts
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.

orders.controller.ts
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);
  }
}
commerce.module.ts
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:

Output of the e-commerce slice
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

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

GraphQL: Code-First & Schema-First

Persistence

MongoDB through Mongoose; posts as documents with a compound index for timelines; counters updated with atomic operators

MongoDB with Mongoose

Real time

Subscriptions over the graphql-ws protocol, authenticated once when the socket connects

GraphQL: Subscriptions, Security & Plugins

Fan-out

In-memory PubSub on one instance; a Redis-backed PubSub as soon as there are several

GraphQL: Subscriptions, Security & Plugins

Authentication

One token reader used by the HTTP context and by the WebSocket onConnect hook

Authentication

N+1

A DataLoader per request for author lookups

Performance Optimization

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

GraphQL Federation

Tests

HTTP operations with supertest; subscriptions with a real graphql-ws client

Testing GraphQL, WebSockets & Microservices

For the language-neutral side (schema design, pagination, the N+1 problem), see the GraphQL guide.

sequenceDiagram participant A as Alice (subscriber) participant B as Bob participant S as Nest GraphQL server participant M as MongoDB A->>S: WebSocket connect, connectionParams.authorization S->>S: onConnect verifies the token (4403 if invalid) A->>S: subscribe postLiked(postId) B->>S: mutation likePost(postId) S->>M: findOneAndUpdate({_id, likedBy: {$ne: bob}}, $addToSet, $inc) M-->>S: updated post, or null if bob already liked S->>S: pubSub.publish(postLiked) S-->>A: next { postLiked } (filter: same postId) S-->>B: mutation result

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.

post.schema.ts
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.

posts.service.ts
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();
  }
}

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.

post.model.ts
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;
}
auth.ts
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.

posts.resolver.ts
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.

social.module.ts
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:

Output of the social-network slice
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

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

Microservices Architecture & Patterns

Messaging

Request/response commands over a transport (TCP here; RabbitMQ, Kafka or NATS in production); events on a broker

Microservices Fundamentals, Microservice Transports

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

Outbox & Durable Workflows

Idempotent handlers

Every command can be applied twice with the same effect

Resilience, Idempotency & Locks

Background work

BullMQ queues for reports, imports and e-mail; cron for periodic jobs

Events, Scheduling & Queues

Mutual exclusion

Distributed locks with fencing tokens, per order and per scheduled job

Resilience, Idempotency & Locks

Failure isolation

Timeouts, retries of safe calls only, circuit breakers and bulkheads between services

Resilience, Idempotency & Locks

Read models

CQRS projections for reporting screens, fed by events

CQRS

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

Testing GraphQL, WebSockets & Microservices

sequenceDiagram participant C as Client participant G as ERP gateway participant L as Lock store participant I as Inventory service participant B as Billing service C->>G: POST /erp/orders G->>L: withLock(erp:order:ID) L-->>G: acquired (duplicate request: 409) G->>I: inventory.reserve I-->>G: reserved G->>B: billing.charge alt charge accepted B-->>G: charged G-->>C: 201 confirmed else charge refused B-->>G: error CREDIT_LIMIT_EXCEEDED G->>I: inventory.release (compensation) G-->>C: 422 rolled back end G->>L: release

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.

inventory.controller.ts
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 };
  }
}
billing.controller.ts
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 };
  }
}
contracts.ts
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;
}

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.

order-saga.service.ts
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.

erp.module.ts
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 {}

The slice’s test starts the two services as TCP microservices on local ports and the gateway as an HTTP application:

Output of the ERP slice
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 reserve and before charge, 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/resilience policies: 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/cqrs sagas 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/resilience for 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/idempotency and @nestjs/resilience now cover these (all pre-1.0, so pin versions).

  • Authentication. Passport strategies still work, but a guard around JwtService is often enough, and the pre-1.0 @nestjs/authentication package exists. JWT secrets always come from configuration, and synchronize: true in TypeORM is for demos only.

  • GraphQL subscriptions. The old subscriptions-transport-ws protocol was removed; use graphql-ws. GraphiQL is the default IDE in the current Apollo setup.

  • Module format. Core packages are ESM-only in 12 and generated projects use .js import 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/workflows and @nestjs/authentication are 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() and manager.decrement() with a conditional where, as shown, on the better-sqlite3 driver.

  • 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.