Mastering advanced patterns unlocks the full power of NestJS. This chapter covers CQRS, GraphQL, WebSockets, event sourcing, and real-world case studies for building scalable, modern applications. We’ll walk through practical examples and explain when to use each pattern.
Think of a library. The checkout desk (command side) handles lending books, accepting returns, and processing fines — these are write operations that change the state of the library. The catalog computer (query side) lets visitors search for books, check availability, and browse by genre — these are read operations that never change anything. In a small library, one librarian does both jobs. In a busy city library, you have separate staff and separate systems for each, because the checkout desk needs to be fast and transactional, while the catalog needs to be optimized for search. That is exactly why CQRS exists: when your read and write workloads have fundamentally different performance characteristics, separating them lets you optimize each independently.
Commands are simple data classes — they carry the intent (“create this user”) and the data needed to fulfill it. The handler is where the actual business logic lives. This separation makes it trivial to add cross-cutting concerns (logging, validation, event publishing) to all commands via decorators or middleware.
// users/commands/create-user.command.ts// Commands are named as imperative verbs: "Create", "Update", "Delete".// They are plain classes with no behavior -- just data.export class CreateUserCommand { constructor( public readonly name: string, public readonly email: string, ) {}}// users/commands/handlers/create-user.handler.tsimport { CommandHandler, ICommandHandler } from '@nestjs/cqrs';import { CreateUserCommand } from '../create-user.command';import { UsersService } from '../../users.service';// @CommandHandler binds this handler to CreateUserCommand.// When commandBus.execute(new CreateUserCommand(...)) is called,// NestJS routes it to this handler's execute() method.@CommandHandler(CreateUserCommand)export class CreateUserHandler implements ICommandHandler<CreateUserCommand> { constructor(private usersService: UsersService) {} // The execute method contains the business logic for this command. // It can do validation, call services, publish events, etc. async execute(command: CreateUserCommand) { const { name, email } = command; return this.usersService.create({ name, email }); }}
// users/users.module.tsimport { Module } from '@nestjs/common';import { CqrsModule } from '@nestjs/cqrs';import { UsersController } from './users.controller';import { CreateUserHandler } from './commands/handlers/create-user.handler';import { GetUserHandler } from './queries/handlers/get-user.handler';@Module({ imports: [CqrsModule], controllers: [UsersController], providers: [ CreateUserHandler, GetUserHandler, // ... other handlers ],})export class UsersModule {}
Best Practices:
Use separate models for reads and writes — the write model can be normalized for data integrity, while the read model can be denormalized for query performance
Implement event sourcing for auditability when you need a full history of changes
Use CQRS for complex domains, not simple CRUD — adding CQRS to a basic user management API is overengineering that will slow your team down with no benefit
Keep commands and queries focused — each should do exactly one thing
When NOT to Use CQRS: If your application is primarily CRUD with simple business rules, CQRS adds layers of indirection (command classes, handler classes, bus routing) that make the code harder to follow without providing proportional benefit. As a rule of thumb, if your service methods are fewer than 20 lines and rarely need to coordinate multiple operations, stick with the standard controller-service-repository pattern.
NestJS supports GraphQL out of the box with two approaches: code-first (define your schema using TypeScript decorators — the schema.gql file is auto-generated) and schema-first (write the .graphql schema file manually, then generate TypeScript types). Code-first is more popular in the NestJS ecosystem because it keeps your types and schema in sync automatically. GraphQL lets clients request exactly the fields they need — no more over-fetching 50 user fields when you only need name and email.
3+ (web, mobile, partner API, each needing different fields)
Data shape
Predictable, well-defined resources
Highly variable, nested relationships
Caching
Easy (HTTP caching, CDN, ETags)
Hard (query-level caching requires tools like Apollo Cache)
File uploads
Native (multipart/form-data)
Awkward (requires separate endpoint or complex spec)
Real-time
Requires separate WebSocket setup
Built-in subscriptions
Tooling
Postman, curl, any HTTP client
GraphQL Playground, Apollo DevTools
Team familiarity
Universal
Requires GraphQL-specific knowledge
API evolution
Versioning (v1/v2) or content negotiation
Field deprecation (no versioning needed)
Practical Advice: You do not have to choose one. Many production NestJS applications use REST for public APIs (easy to cache, familiar to consumers) and GraphQL for internal frontends (flexible queries, no over-fetching). NestJS supports both in the same application simultaneously.
This is by far the most common pitfall. CQRS, event sourcing, and GraphQL are powerful tools, but they come with real costs: more files, more indirection, steeper learning curves for new team members. If your team spends more time maintaining the infrastructure of CQRS than writing business logic, you over-engineered. Start with the simplest architecture that works and add complexity only when you feel real pain.
Mixing command and query logic in CQRS defeats the purpose. A command handler that also queries the database to return data is not CQRS — it is a regular service with extra steps. Commands should return at most an ID or acknowledgment; the client should use a query to fetch the created resource.
Not documenting message/event formats leads to confusion between teams. When Service A publishes a user_created event, Service B needs to know the exact payload shape. Use shared DTOs or schema registries to keep contracts in sync. This is especially critical because these contracts are not enforced by the compiler — a missing field will cause a runtime error, not a build error.
WebSocket endpoints and GraphQL resolvers need authentication just like REST endpoints. It is easy to forget because they feel “different” from traditional HTTP routes. Always apply JWT guards to WebSocket gateways (in handleConnection) and to GraphQL resolvers (using @UseGuards). GraphQL is particularly risky because a single unprotected resolver can expose your entire data model through introspection.
GraphQL’s flexibility is a double-edged sword. Clients can write deeply nested queries that join 10 tables, and your server will happily try to resolve them. Use query depth limiting (depthLimit), query complexity analysis, and DataLoader to prevent N+1 query explosions. Always profile before and after adding advanced patterns — measure, do not guess.
Edge Case 1: CQRS command returns data — is that allowed?Purist CQRS says commands should return void (or at most an ID), because commands change state and queries read state — mixing them violates the separation. In practice, returning the created entity’s ID from a command handler is fine and avoids an extra round-trip query. What you should not do is return a fully populated entity from a command handler, because that couples the write model to the read model.Edge Case 2: GraphQL N+1 with nested resolversIf a User type has a posts field resolver, and you query { users { posts { title } } } for 100 users, the posts resolver fires 100 times — one database query per user. DataLoader batches these into a single query. Without DataLoader, this pattern destroys database performance.
// Without DataLoader: 100 users = 1 query for users + 100 queries for posts = 101 queries// With DataLoader: 100 users = 1 query for users + 1 query for all posts = 2 queriesimport DataLoader from 'dataloader';// Create a DataLoader that batches user IDs into a single posts queryconst postsLoader = new DataLoader(async (userIds: number[]) => { const posts = await this.postsService.findByUserIds(userIds); // DataLoader requires results in the same order as input IDs return userIds.map(id => posts.filter(p => p.authorId === id));});
Edge Case 3: WebSocket scaling across multiple server instancesIf you run 3 NestJS instances behind a load balancer, a WebSocket connection is bound to one instance. When Instance A emits an event, clients connected to Instances B and C do not receive it. Use the Redis adapter for Socket.io to broadcast events across all instances:
import { IoAdapter } from '@nestjs/platform-socket.io';import { createAdapter } from '@socket.io/redis-adapter';import { createClient } from 'redis';export class RedisIoAdapter extends IoAdapter { createIOServer(port: number, options?: any) { const server = super.createIOServer(port, options); const pubClient = createClient({ url: 'redis://localhost:6379' }); const subClient = pubClient.duplicate(); server.adapter(createAdapter(pubClient, subClient)); return server; }}
Edge Case 4: Event sourcing and eventual consistencyWhen you separate the write model (events) from the read model (projections), the read model is always slightly behind. If a user creates a resource and immediately queries for it, the projection may not have processed the event yet, returning “not found.” Solutions: (1) Return the created entity directly from the command handler (breaks strict CQRS but is pragmatic); (2) Use a synchronous event handler that updates the projection before the command returns; (3) Accept eventual consistency and handle it in the frontend (show optimistic UI).