NestJS Architecture -- Deep Dive¶
NestJS enforces modular architecture through its module/controller/service pattern with a compile-time dependency injection container. Its layered execution pipeline (middleware, guards, interceptors, pipes, exception filters) mirrors Spring's filter chain but runs on Node.js. First-class WebSocket gateway support and tight BullMQ integration make it the right choice for Warmwind's real-time VNC relay and container orchestration backend.
1. Module System & Dependency Injection¶
Every NestJS application is a tree of modules. Each module declares its controllers, providers (services), imports, and exports -- forming explicit dependency boundaries that the DI container wires at bootstrap time.
graph LR
AppModule --> AuthModule
AppModule --> ContainerModule
AppModule --> AgentModule
AppModule --> HealthModule
ContainerModule --> DockerService
ContainerModule --> VncGateway
ContainerModule --> BullQueue["BullMQ Queue (Redis)"]
AgentModule --> AiInferenceService
AgentModule --> AgentEventsGateway
AuthModule --> JwtStrategy
AuthModule --> TenantService
1.1 Module Anatomy¶
A module is a class decorated with @Module(). It is the compilation unit of DI -- providers declared in one module are invisible to another unless explicitly exported and imported.
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { ContainerController } from './container.controller';
import { ContainerService } from './container.service';
import { ContainerProcessor } from './container.processor';
import { VncGateway } from './vnc.gateway';
import { DockerService } from './docker.service';
@Module({
imports: [
BullModule.registerQueue({ name: 'containers' }),
],
controllers: [ContainerController],
providers: [
ContainerService,
ContainerProcessor,
VncGateway,
DockerService,
],
exports: [ContainerService], // visible to importing modules
})
export class ContainerModule {}
1.2 Provider Scopes¶
| Scope | Lifetime | Analogy (Spring) | Use Case |
|---|---|---|---|
SINGLETON |
One instance for the entire app (default) | @Scope("singleton") |
Stateless services, DB repositories |
REQUEST |
New instance per incoming HTTP request or GraphQL operation | @Scope("request") |
Multi-tenant context, per-request audit trail |
TRANSIENT |
New instance every time it is injected | @Scope("prototype") |
Stateful helpers, unique per-consumer loggers |
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
private readonly tenantId: string;
constructor(@Inject(REQUEST) private readonly request: Request) {
// Resolved once per HTTP request -- the constructor is your
// "request-scoped initializer," similar to Spring's request bean proxy.
this.tenantId = this.request.headers['x-tenant-id'] as string;
}
getTenantId(): string {
return this.tenantId;
}
}
REQUEST scope bubbles up
If a SINGLETON depends on a REQUEST-scoped provider, the singleton silently
becomes request-scoped too. This can multiply object allocations by orders of
magnitude under load. Audit the scope chain with nest info and keep
request-scoped providers at leaf positions. Prefer passing tenant context
through a ClsModule (continuation-local storage) to avoid scope bubbling
entirely.
Coming from Spring
NestJS DI is Spring's @Autowired + @Configuration compressed into
decorators. Module = @Configuration class. Provider = @Bean
method or @Component-scanned class. exports array = making a bean
visible outside its configuration boundary. The key difference: Spring scans
packages automatically by default; NestJS requires explicit imports, which
eliminates "magic bean" surprises but demands more wiring discipline.
1.3 Custom Providers & Factory Patterns¶
When you need runtime logic to create a provider (environment-dependent config, feature flags), use factory providers:
const DockerProvider = {
provide: 'DOCKER_CLIENT',
useFactory: (config: ConfigService) => {
const socketPath = config.get<string>('DOCKER_SOCKET', '/var/run/docker.sock');
return new Dockerode({ socketPath });
},
inject: [ConfigService],
};
@Module({
providers: [DockerProvider, ContainerService],
exports: ['DOCKER_CLIENT'],
})
export class DockerModule {}
// Consuming the factory-provided token
@Injectable()
export class ContainerService {
constructor(
@Inject('DOCKER_CLIENT') private readonly docker: Dockerode,
) {}
}
Coming from Spring
useFactory is the exact equivalent of a @Bean factory method inside a
@Configuration class. useClass maps to component scanning. useValue
maps to @Value-injected constants. NestJS just makes the registration
explicit rather than annotation-driven.
2. Execution Pipeline¶
Every HTTP request (or WebSocket frame, or GraphQL operation) passes through a strict, ordered pipeline. Understanding this pipeline is essential for placing cross-cutting logic correctly.
graph LR
MW["1 Middleware"] --> GD["2 Guards"] --> INT_PRE["3 Interceptors (pre)"] --> PP["4 Pipes"] --> H["5 Route Handler"] --> INT_POST["6 Interceptors (post)"] --> EF["7 Exception Filters"]
2.1 Middleware¶
Runs first. Has access to raw req, res, next(). Identical to Express middleware. Use for request logging, CORS headers, body parsing, or tenant header extraction.
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class RequestLoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger('HTTP');
use(req: Request, _res: Response, next: NextFunction): void {
const start = Date.now();
_res.on('finish', () => {
this.logger.log(
`${req.method} ${req.originalUrl} ${_res.statusCode} ${Date.now() - start}ms`,
);
});
next();
}
}
// Applied in the module's configure() method
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestLoggerMiddleware).forRoutes('*');
}
}
2.2 Guards¶
Determine whether a request is authorized to proceed. Return true/false (or throw). Guards have full access to the ExecutionContext, which means they can inspect metadata set by decorators.
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ContainerService } from './container.service';
@Injectable()
export class ContainerOwnerGuard implements CanActivate {
constructor(
private readonly containerService: ContainerService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const containerId: string = req.params.id;
const userId: string = req.user?.id;
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const isOwner = await this.containerService.isOwner(containerId, userId);
if (!isOwner) {
throw new ForbiddenException(
`User ${userId} does not own container ${containerId}`,
);
}
return true;
}
}
// Usage on a controller method
@UseGuards(JwtAuthGuard, ContainerOwnerGuard)
@Get(':id/vnc-token')
async getVncToken(@Param('id') id: string): Promise<VncTokenDto> {
return this.containerService.issueVncToken(id);
}
Coming from Spring
Guards = Spring Security's @PreAuthorize annotations or AccessDecisionVoter.
The Reflector utility reads custom metadata from decorators -- it is the NestJS
equivalent of reading Spring @Secured or @RolesAllowed annotation values at
runtime.
2.3 Interceptors¶
Wrap the route handler execution with before and after logic via RxJS Observable. This is the NestJS equivalent of Spring AOP @Around advice.
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
private readonly logger = new Logger('Timing');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now();
const handler = `${context.getClass().name}.${context.getHandler().name}`;
return next.handle().pipe(
tap(() => {
this.logger.log(`${handler} completed in ${Date.now() - start}ms`);
}),
);
}
}
2.4 Pipes¶
Transform or validate input data before it reaches the handler. NestJS ships ValidationPipe (backed by class-validator) and ParseIntPipe, ParseUUIDPipe, etc.
import {
PipeTransform,
Injectable,
BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class StrictValidationPipe implements PipeTransform {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) return value;
const object = plainToInstance(metatype, value);
const errors = await validate(object, {
whitelist: true, // strip unknown properties
forbidNonWhitelisted: true, // throw on unknown properties
});
if (errors.length > 0) {
const messages = errors.flatMap((e) =>
Object.values(e.constraints ?? {}),
);
throw new BadRequestException(messages);
}
return object;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
2.5 Exception Filters¶
Catch unhandled exceptions and transform them into structured HTTP responses. They run last in the pipeline.
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
this.logger.error(
`${request.method} ${request.url} -> ${status}`,
exception instanceof Error ? exception.stack : undefined,
);
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
3. Custom Decorators¶
Decorators are NestJS's composition primitive. They replace Spring annotations and can combine metadata, validation, and extraction.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../auth/jwt-payload.interface';
/**
* Extracts the authenticated user (or a specific field) from the request.
* Usage:
* @Get('profile') getProfile(@CurrentUser() user: JwtPayload)
* @Get('profile') getProfile(@CurrentUser('id') userId: string)
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user: JwtPayload = request.user;
return data ? user?.[data] : user;
},
);
Compose multiple decorators into one using applyDecorators:
import { applyDecorators, UseGuards, SetMetadata } from '@nestjs/common';
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(JwtAuthGuard, RolesGuard),
);
}
// Usage: @Auth('admin') instead of stacking three decorators
4. WebSocket Gateway -- VNC Relay¶
Critical for Warmwind's real-time VNC streaming between browser clients and Docker containers. The gateway manages Socket.io rooms, authenticates via JWT handshake, and proxies RFB frames bidirectionally.
graph LR
Browser["Browser (noVNC)"] -->|Socket.io| GW["VncGateway"]
GW -->|TCP RFB| Container["Docker Container :5900"]
GW -->|Room: container:abc| Redis["Redis Adapter"]
Redis -->|Fan-out| GW2["VncGateway (Node 2)"]
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
WsException,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { WsJwtGuard } from '../auth/ws-jwt.guard';
import { VncProxyService } from './vnc-proxy.service';
import { SessionService } from './session.service';
interface VncInputEvent {
containerId: string;
type: 'key' | 'pointer' | 'clipboard';
data: Uint8Array;
}
@WebSocketGateway({
namespace: '/vnc',
cors: { origin: process.env.FRONTEND_ORIGIN ?? '*' },
transports: ['websocket'], // skip HTTP long-poll for latency
})
export class VncGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(VncGateway.name);
constructor(
private readonly vncProxy: VncProxyService,
private readonly sessionService: SessionService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
// Authenticate via JWT in handshake auth header
const user = await this.sessionService.authenticateSocket(client);
const containerId = client.handshake.query.containerId as string;
if (!containerId) {
throw new WsException('containerId query parameter required');
}
// Verify ownership before joining the room
await this.sessionService.verifyOwnership(user.id, containerId);
client.join(`container:${containerId}`);
client.data = { userId: user.id, containerId };
// Open upstream TCP connection to the container's VNC server
await this.vncProxy.connect(client.id, containerId);
this.logger.log(
`Client ${client.id} connected to container ${containerId}`,
);
} catch (err) {
this.logger.warn(`Connection rejected: ${err.message}`);
client.disconnect(true);
}
}
@SubscribeMessage('vnc:input')
handleInput(
@ConnectedSocket() client: Socket,
@MessageBody() payload: VncInputEvent,
): void {
// Forward keyboard/mouse/clipboard events to the container's VNC server
this.vncProxy.forward(client.data.containerId, payload);
}
@SubscribeMessage('vnc:resize')
async handleResize(
@ConnectedSocket() client: Socket,
@MessageBody() payload: { width: number; height: number },
): Promise<void> {
await this.vncProxy.resize(client.data.containerId, payload);
}
async handleDisconnect(client: Socket): Promise<void> {
const { containerId } = client.data ?? {};
if (containerId) {
await this.vncProxy.disconnect(client.id);
this.logger.log(
`Client ${client.id} disconnected from container ${containerId}`,
);
}
await this.sessionService.cleanup(client.id);
}
}
5. BullMQ Job Queue -- Container Lifecycle¶
Container creation, snapshotting, and teardown are long-running operations that must not block the HTTP response cycle. BullMQ provides Redis-backed, persistent, retryable job queues.
import { Injectable, Logger } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue, Job } from 'bullmq';
interface StartContainerDto {
userId: string;
image: string;
resourceLimits: { cpus: number; memoryMb: number };
}
@Injectable()
export class ContainerService {
private readonly logger = new Logger(ContainerService.name);
constructor(
@InjectQueue('containers') private readonly queue: Queue,
) {}
async startContainer(dto: StartContainerDto): Promise<{ jobId: string }> {
const job = await this.queue.add('start', dto, {
attempts: 3,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { age: 3600 }, // keep completed jobs for 1h
removeOnFail: { age: 86_400 }, // keep failed jobs for 24h
priority: dto.resourceLimits.cpus > 4 ? 10 : 1, // lower = higher priority
});
this.logger.log(`Enqueued container start job ${job.id} for ${dto.image}`);
return { jobId: job.id! };
}
async stopContainer(containerId: string): Promise<{ jobId: string }> {
const job = await this.queue.add('stop', { containerId }, {
attempts: 2,
backoff: { type: 'fixed', delay: 1_000 },
});
return { jobId: job.id! };
}
}
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';
import { DockerService } from './docker.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Processor('containers', {
concurrency: 5,
limiter: { max: 10, duration: 60_000 }, // max 10 jobs per minute
})
export class ContainerProcessor extends WorkerHost {
private readonly logger = new Logger(ContainerProcessor.name);
constructor(
private readonly docker: DockerService,
private readonly events: EventEmitter2,
) {
super();
}
async process(job: Job): Promise<any> {
switch (job.name) {
case 'start':
return this.handleStart(job);
case 'stop':
return this.handleStop(job);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
}
private async handleStart(job: Job<StartContainerDto>): Promise<string> {
this.logger.log(`Starting container for image ${job.data.image}`);
await job.updateProgress(10);
const containerId = await this.docker.createContainer(job.data);
await job.updateProgress(50);
await this.docker.startContainer(containerId);
await job.updateProgress(100);
// Emit domain event for CQRS saga / subscription notification
this.events.emit('container.started', {
containerId,
userId: job.data.userId,
image: job.data.image,
});
return containerId;
}
private async handleStop(job: Job<{ containerId: string }>): Promise<void> {
await this.docker.stopContainer(job.data.containerId);
this.events.emit('container.stopped', {
containerId: job.data.containerId,
});
}
}
6. CQRS Pattern¶
The @nestjs/cqrs module separates reads from writes via distinct command and query buses, with events propagating side effects through sagas and projections.
graph LR
CMD["StartContainerCommand"] --> CB["CommandBus"]
CB --> CH["StartContainerHandler"]
CH --> EVT["ContainerStartedEvent"]
EVT --> EB["EventBus"]
EB --> SAGA["NotifyUserSaga"]
EB --> PROJ["UpdateDashboardProjection"]
QB["GetContainerQuery"] --> QBus["QueryBus"] --> QH["GetContainerHandler"] --> DB["Read Model (Postgres)"]
// Command
export class StartContainerCommand {
constructor(
public readonly userId: string,
public readonly image: string,
public readonly resourceLimits: { cpus: number; memoryMb: number },
) {}
}
// Command Handler
@CommandHandler(StartContainerCommand)
export class StartContainerHandler
implements ICommandHandler<StartContainerCommand>
{
constructor(private readonly containerService: ContainerService) {}
async execute(command: StartContainerCommand): Promise<{ jobId: string }> {
return this.containerService.startContainer({
userId: command.userId,
image: command.image,
resourceLimits: command.resourceLimits,
});
}
}
// Domain Event
export class ContainerStartedEvent {
constructor(
public readonly containerId: string,
public readonly userId: string,
) {}
}
// Saga: reacts to events and dispatches new commands
@Injectable()
export class ContainerSaga {
@Saga()
containerStarted = (events$: Observable<IEvent>): Observable<ICommand> => {
return events$.pipe(
ofType(ContainerStartedEvent),
map((event) => new NotifyUserCommand(event.userId, 'Container ready')),
);
};
}
Coming from Spring
If you have used Axon Framework or Spring's ApplicationEventPublisher,
the mental model is identical. CommandBus = Axon command bus.
EventBus = ApplicationEventPublisher. @Saga() = Axon saga with
the same "react to events, emit new commands" pattern. The key difference:
NestJS CQRS events are in-memory by default -- for persistence you must
add your own event store (e.g., EventStoreDB or a Postgres outbox table).
7. Testing Patterns¶
NestJS's DI container makes testing straightforward: override any provider with a mock at the module level.
7.1 Unit Testing a Service¶
import { Test, TestingModule } from '@nestjs/testing';
import { getQueueToken } from '@nestjs/bullmq';
import { ContainerService } from './container.service';
describe('ContainerService', () => {
let service: ContainerService;
let mockQueue: { add: jest.Mock };
beforeEach(async () => {
mockQueue = { add: jest.fn().mockResolvedValue({ id: 'job-123' }) };
const module: TestingModule = await Test.createTestingModule({
providers: [
ContainerService,
{ provide: getQueueToken('containers'), useValue: mockQueue },
],
}).compile();
service = module.get(ContainerService);
});
it('enqueues a start job with exponential backoff', async () => {
const dto = {
userId: 'u1',
image: 'node:20',
resourceLimits: { cpus: 2, memoryMb: 512 },
};
const result = await service.startContainer(dto);
expect(result.jobId).toBe('job-123');
expect(mockQueue.add).toHaveBeenCalledWith('start', dto, expect.objectContaining({
attempts: 3,
backoff: { type: 'exponential', delay: 2_000 },
}));
});
});
7.2 Integration Testing a Controller¶
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('ContainerController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(DockerService)
.useValue({
createContainer: jest.fn().mockResolvedValue('ctr-abc'),
startContainer: jest.fn().mockResolvedValue(undefined),
})
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(() => app.close());
it('POST /containers/start returns 201 with jobId', () => {
return request(app.getHttpServer())
.post('/containers/start')
.set('Authorization', `Bearer ${testJwt}`)
.send({ image: 'node:20', resourceLimits: { cpus: 1, memoryMb: 256 } })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty('jobId');
});
});
});
Coming from Spring
Test.createTestingModule() = @SpringBootTest with @MockBean.
overrideProvider() = @MockBean replacement. The pattern is identical:
boot a minimal application context, swap real implementations with mocks,
and hit endpoints via supertest (= MockMvc/WebTestClient).
8. Bootstrap & Configuration¶
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log', 'debug'],
});
// Global validation pipe -- all DTOs validated automatically
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Global exception filter
app.useGlobalFilters(new GlobalExceptionFilter());
// WebSocket adapter (swap for RedisIoAdapter in production)
app.useWebSocketAdapter(new IoAdapter(app));
// Required for onModuleDestroy hooks
app.enableShutdownHooks();
const port = process.env.PORT ?? 3000;
await app.listen(port);
Logger.log(`Application listening on port ${port}`, 'Bootstrap');
}
bootstrap();
What's new (2025--2026)
NestJS 11 ships Express v5 and Fastify v5 with breaking route syntax
changes (path parameter patterns updated from :id regex to Express v5
syntax). New features include built-in structured JSON logging (replacing
the console-based default logger), IntrinsicException for cleaner internal
error handling, enhanced CQRS support with improved event-sourcing patterns,
and first-class SWC compilation support for faster builds.
Glossary¶
Glossary
- Module
- Organizational unit decorated with
@Module()that groups related controllers, services, and providers into a cohesive boundary. Equivalent to a Spring@Configurationclass. - Provider
- Any class or value registered in a module's
providersarray that the DI container can inject. Services, repositories, factories, and guards are all providers. - Dependency Injection (DI)
- Pattern where the framework resolves and injects class dependencies automatically based on constructor type signatures and injection tokens.
- Guard
- Pipeline layer that determines whether a request should proceed, typically used for authentication and authorization. Returns
booleanfromcanActivate(). - Interceptor
- Wraps route handler execution via RxJS
Observablefor cross-cutting concerns like logging, caching, response transformation, or timeout enforcement. - Pipe
- Transforms or validates input data before it reaches the route handler. Built-in pipes include
ValidationPipe,ParseIntPipe,ParseUUIDPipe. - Exception Filter
- Catches thrown exceptions and maps them to structured HTTP/WS responses. Runs last in the pipeline.
- Middleware
- Express-compatible function running before guards. Has access to
req,res,next()but not toExecutionContext. - Gateway
- WebSocket entry point decorated with
@WebSocketGateway()for real-time bidirectional communication over Socket.io or native WebSocket. - CQRS
- Command Query Responsibility Segregation -- separates read and write operations into distinct models connected by domain events.
- Saga
- CQRS component that listens to domain events and dispatches new commands, orchestrating multi-step business processes.
- BullMQ
- Redis-backed job queue library supporting delayed jobs, retries with backoff, rate limiting, concurrency control, and job prioritization. Successor to Bull.
- Execution Context
- NestJS abstraction (
ExecutionContext) that unifies HTTP, WebSocket, and RPC contexts, allowing guards/interceptors to work across transport layers. - Decorator
- TypeScript language feature (stage 3 proposal, finalized) used by NestJS to attach metadata to classes, methods, and parameters. Equivalent to Java/Scala annotations.