NestJS Crash Course for Forma3D Connect¶
Target Audience: Junior developers with TypeScript knowledge and Node.js basics
Goal: Get up to speed to independently work on this project's NestJS backend
Table of Contents¶
- What is NestJS?
- Core Concepts
- Modules: Organizing Your Application
- Controllers: Handling HTTP Requests
- Services: Business Logic
- Repositories: Database Access
- Dependency Injection
- DTOs and Validation
- Guards: Authentication & Authorization
- Events: Decoupled Communication
- BullMQ: Job Queues and Event Bus
- WebSockets: Real-Time Communication
- Prisma: Database ORM
- Multi-Tenancy
- Configuration Management
- Observability: Logging, Tracing, and Error Tracking
- Testing NestJS Applications
- Microservice Architecture
- Project Structure and Conventions
- Common Patterns in This Codebase
- Quick Reference
1. What is NestJS?¶
NestJS is a framework for building server-side Node.js applications. It combines:
- TypeScript for type safety
- Express 5 under the hood for HTTP handling
- Decorators for declarative programming
- Dependency Injection for loose coupling and testability
- Modular architecture for scalability
Why NestJS?¶
| Feature | Benefit |
|---|---|
| Structured architecture | Enforces consistent patterns across the team |
| Dependency injection | Makes testing and mocking easy |
| Decorators | Less boilerplate, more readable code |
| TypeScript-first | Catches errors at compile time |
| Enterprise patterns | Guards, pipes, interceptors, filters |
| Microservices support | Built-in patterns for distributed systems |
Mental Model¶
Think of NestJS as having three main layers:
2. Core Concepts¶
Decorators¶
Decorators are special annotations that add metadata to classes, methods, and parameters. They start with @.
@Controller('api/v1/orders') // Class decorator - marks this as a controller
export class OrdersController {
@Get() // Method decorator - handles GET requests
findAll(): Order[] {
return [];
}
@Get(':id') // Route parameter
findOne(@Param('id') id: string): Order {
// Parameter decorator
return this.ordersService.findById(id);
}
}
Key Decorators You'll Use¶
| Decorator | Purpose | Example |
|---|---|---|
@Controller() |
Defines a controller | @Controller('api/v1/orders') |
@Injectable() |
Marks a class for DI | @Injectable() |
@Module() |
Defines a module | @Module({ providers: [...] }) |
@Get(), @Post(), etc. |
HTTP method handlers | @Get(':id') |
@Body(), @Param(), @Query() |
Request data extraction | @Body() dto: CreateOrderDto |
@UseGuards() |
Apply authentication | @UseGuards(SessionGuard) |
@RequirePermissions() |
Permission check | @RequirePermissions('orders.write') |
3. Modules: Organizing Your Application¶
Modules are the building blocks of NestJS applications. Each module encapsulates a feature.
Basic Module Structure¶
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
@Module({
imports: [EventLogModule], // Other modules this module depends on
controllers: [OrdersController], // HTTP controllers
providers: [OrdersService, OrdersRepository], // Services, repos
exports: [OrdersService], // What other modules can use
})
export class OrdersModule {}
Real Example from Our Codebase¶
From apps/order-service/src/orders/orders.module.ts:
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { OrdersRepository } from './orders.repository';
import { EventLogModule, ShipmentsModule } from '@forma3d/service-common';
import { ORDERS_SERVICE } from '@forma3d/domain-contracts';
@Module({
imports: [EventLogModule, ShipmentsModule],
controllers: [OrdersController],
providers: [
OrdersService,
OrdersRepository,
{
provide: ORDERS_SERVICE,
useExisting: OrdersService,
},
],
exports: [OrdersService, ORDERS_SERVICE],
})
export class OrdersModule {}
Key concepts:
- imports: Brings in shared modules from
@forma3d/service-common - providers: Registers services for dependency injection
- exports: Makes
OrdersServiceavailable to other modules - Token-based provide: Registers
OrdersServiceunder theORDERS_SERVICEtoken for cross-service contracts
The Root Module (Microservice)¶
From apps/order-service/src/app/app.module.ts:
@Module({
imports: [
// Infrastructure
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
SharedObservabilityModule.forRoot({ includeBusinessObservability: true }),
SharedConfigModule.forRoot({ load: [configuration] }),
DatabaseModule,
CorrelationModule,
AppThrottlerModule,
// Event Bus (BullMQ via Redis)
EventBusModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
redisUrl: configService.get<string>('REDIS_URL') || 'redis://localhost:6379',
}),
inject: [ConfigService],
}),
// Auth & Tenancy
InternalAuthModule,
SharedAuthModule.forRoot(),
TenancyModule,
// Shared Services
ServiceClientModule,
AuditModule.forRoot({ controller: AuditController }),
// Domain Modules
OrdersModule,
ProductMappingsModule,
ShipmentsModule,
// ...
],
controllers: [AuthController],
providers: [
NotificationEventHandlers,
{ provide: APP_GUARD, useClass: SessionGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: ApiVersionInterceptor },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(SecurityHeadersMiddleware).forRoutes('*');
consumer.apply(UserContextMiddleware).forRoutes('*');
consumer.apply(CorrelationMiddleware).forRoutes('*');
}
}
Key observations:
APP_GUARDregisters guards globally — every endpoint gets session + permission checksAPP_INTERCEPTORregisters interceptors globally — API versioning, deprecation headers- Middleware runs on every request — security headers, user context extraction, correlation IDs
- Shared modules from
@forma3d/service-commonprovide cross-cutting concerns (auth, audit, database, observability)
4. Controllers: Handling HTTP Requests¶
Controllers are responsible for:
- Receiving HTTP requests
- Validating input (via pipes/DTOs)
- Calling services
- Returning responses
Basic Controller¶
import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common';
@Controller('api/v1/orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get() // GET /api/v1/orders
async findAll(@Query() query: OrderQueryDto) {
return this.ordersService.findAll(query);
}
@Get(':id') // GET /api/v1/orders/:id
async findById(@Param('id') id: string) {
return this.ordersService.findById(id);
}
@Post() // POST /api/v1/orders
async create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
}
Real Example with Permissions and Swagger¶
From apps/order-service/src/orders/orders.controller.ts:
import { Controller, Get, Param, Query, Put, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger';
import { RequirePermissions } from '@forma3d/service-common';
@ApiTags('Orders')
@Controller('api/v1/orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
@RequirePermissions('orders.read')
@ApiOperation({
summary: 'List orders',
description: 'Retrieve a paginated list of orders for the current tenant.',
})
@ApiResponse({
status: 200,
description: 'Orders retrieved successfully',
type: OrderListResponseDto,
})
async findAll(@Query() query: OrderQueryDto): Promise<OrderListResponseDto> {
const result = await this.ordersService.findAll({
status: query.status,
page: query.page,
pageSize: query.pageSize,
});
return {
orders: result.orders.map((order) => this.toDto(order)),
total: result.total,
page: result.page,
pageSize: result.pageSize,
};
}
@Put(':id/cancel')
@HttpCode(HttpStatus.OK)
@RequirePermissions('orders.write')
@ApiOperation({ summary: 'Cancel order' })
@ApiParam({ name: 'id', description: 'Order UUID' })
@ApiResponse({ status: 200, description: 'Order cancelled' })
@ApiResponse({ status: 404, description: 'Order not found' })
async cancel(@Param('id') id: string): Promise<OrderResponseDto> {
const order = await this.ordersService.cancelOrder(id);
return this.toDto(order);
}
private toDto(order: Order): OrderResponseDto {
return {
id: order.id,
status: order.status,
// ... mapping logic
};
}
}
Controller Responsibilities (Project Rules)¶
- Handle HTTP only — no business logic
- Validate input — use DTOs with class-validator
- Transform output — convert entities to DTOs
- Declare permissions — use
@RequirePermissions()for RBAC - Return proper status codes — use
@HttpCode()when needed
5. Services: Business Logic¶
Services contain the core business logic. They:
- Implement domain rules
- Coordinate between repositories
- Emit events
- Handle transactions
Basic Service¶
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly eventEmitter: EventEmitter2,
private readonly eventLogService: EventLogService
) {}
async findById(id: string): Promise<Order> {
const order = await this.ordersRepository.findById(id);
if (!order) {
throw new NotFoundException(`Order ${id} not found`);
}
return order;
}
async create(input: CreateOrderInput): Promise<Order> {
this.logger.log(`Creating order for ${input.customerName}`);
const order = await this.ordersRepository.create(input);
await this.eventLogService.log({
orderId: order.id,
eventType: 'order.created',
severity: 'INFO',
message: 'Order created',
});
this.eventEmitter.emit('order.created', { orderId: order.id });
return order;
}
}
Real Example with Full Business Logic¶
From apps/order-service/src/orders/orders.service.ts:
@Injectable()
export class OrdersService implements IOrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly eventEmitter: EventEmitter2,
private readonly eventLogService: EventLogService,
private readonly correlationService: CorrelationService
) {}
/**
* Create order from Shopify webhook data.
* Implements idempotency — returns existing order if already exists.
*/
async createFromShopify(input: CreateOrderInput, webhookId: string): Promise<OrderWithLineItems> {
const existing = await this.ordersRepository.findByShopifyOrderId(input.shopifyOrderId);
if (existing) {
this.logger.debug(`Order ${input.shopifyOrderId} already exists, skipping creation`);
return existing;
}
const order = await this.ordersRepository.create(input);
this.logger.log(`Created order ${order.id} from Shopify order ${input.shopifyOrderId}`);
await this.eventLogService.log({
orderId: order.id,
eventType: 'order.created',
severity: 'INFO',
message: `Order created from Shopify webhook`,
metadata: { webhookId, shopifyOrderNumber: input.shopifyOrderNumber },
});
this.eventEmitter.emit(
ORDER_EVENTS.CREATED,
OrderCreatedEvent.create(
this.correlationService.getOrCreateCorrelationId(),
order.id,
input.shopifyOrderId,
order.lineItems.length
)
);
return order;
}
async cancelOrder(id: string, reason?: string): Promise<OrderWithLineItems> {
const order = await this.findById(id);
if (order.status === OrderStatus.COMPLETED) {
throw new ConflictException('Cannot cancel a completed order');
}
await this.ordersRepository.updateStatus(id, OrderStatus.CANCELLED);
await this.eventLogService.log({
orderId: id,
eventType: 'order.cancelled',
severity: 'WARNING',
message: reason || 'Order cancelled',
});
this.eventEmitter.emit(
ORDER_EVENTS.CANCELLED,
OrderCancelledEvent.create(this.correlationService.getOrCreateCorrelationId(), id, reason)
);
return this.findById(id);
}
private validateStatusTransition(from: OrderStatus, to: OrderStatus): void {
const validTransitions: Record<OrderStatus, OrderStatus[]> = {
[OrderStatus.PENDING]: [OrderStatus.PROCESSING, OrderStatus.CANCELLED],
[OrderStatus.PROCESSING]: [OrderStatus.COMPLETED, OrderStatus.FAILED],
[OrderStatus.COMPLETED]: [],
[OrderStatus.CANCELLED]: [],
};
if (!validTransitions[from].includes(to)) {
throw new ConflictException(`Invalid status transition from ${from} to ${to}`);
}
}
}
Service Responsibilities (Project Rules)¶
- Business logic only — no HTTP concerns, no direct database queries
- Use repository for data access — never call Prisma directly
- Emit events — for cross-module communication
- Log significant operations — use EventLogService for auditable business events, Logger for operational logs
- Throw typed exceptions —
NotFoundException,ConflictException, etc. - Implement idempotency — check for existing resources before creating duplicates
6. Repositories: Database Access¶
Repositories encapsulate all database operations. This keeps Prisma usage isolated and makes testing easier.
Real Example with Multi-Tenancy¶
From apps/order-service/src/orders/orders.repository.ts:
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@forma3d/service-common';
import { Order, OrderStatus, Prisma } from '@prisma/client';
const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001';
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateOrderInput): Promise<OrderWithLineItems> {
const tenantId = input.tenantId || DEFAULT_TENANT_ID;
return this.prisma.order.create({
data: {
tenantId,
shopifyOrderId: input.shopifyOrderId,
shopifyOrderNumber: input.shopifyOrderNumber,
customerName: input.customerName,
lineItems: {
create: input.lineItems.map((item) => ({
tenantId,
shopifyLineItemId: item.shopifyLineItemId,
productSku: item.productSku,
quantity: item.quantity,
})),
},
},
include: { lineItems: true },
});
}
async findByShopifyOrderId(
shopifyOrderId: string,
tenantId: string = DEFAULT_TENANT_ID,
): Promise<OrderWithLineItems | null> {
return this.prisma.order.findFirst({
where: { tenantId, shopifyOrderId },
include: { lineItems: true },
});
}
async findAll(params: {
tenantId?: string;
status?: OrderStatus;
skip?: number;
take?: number;
}): Promise<{ orders: OrderWithLineItems[]; total: number }> {
const tenantId = params.tenantId || DEFAULT_TENANT_ID;
const where: Prisma.OrderWhereInput = {
tenantId,
...(params.status ? { status: params.status } : {}),
};
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where,
skip: params.skip,
take: params.take,
orderBy: { createdAt: 'desc' },
include: { lineItems: true },
}),
this.prisma.order.count({ where }),
]);
return { orders, total };
}
async updateStatus(id: string, status: OrderStatus): Promise<Order> {
return this.prisma.order.update({
where: { id },
data: {
status,
...(status === OrderStatus.COMPLETED ? { completedAt: new Date() } : {}),
},
});
}
}
Repository Responsibilities (Project Rules)¶
- Database operations only — no business logic
- Always include tenantId — multi-tenancy is enforced at the data layer
- Use Prisma types — leverage generated types from the schema
- Handle relations — use
includefor eager loading - Define clear input/output types — interfaces for data shapes
7. Dependency Injection¶
Dependency Injection (DI) is how NestJS wires your classes together. Instead of creating instances manually, you declare dependencies in the constructor, and NestJS provides them.
How It Works¶
// 1. Mark class as injectable
@Injectable()
export class OrdersService {
// 2. Declare dependencies in constructor
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly eventEmitter: EventEmitter2
) {}
}
// 3. Register in module
@Module({
providers: [OrdersService, OrdersRepository],
})
export class OrdersModule {}
NestJS automatically:
- Creates a single instance of each provider (singleton by default)
- Injects dependencies when creating instances
- Manages the lifecycle (init, destroy)
Token-Based Injection¶
Used extensively for cross-service contracts:
// Define a token in @forma3d/domain-contracts
export const ORDERS_SERVICE = Symbol('IOrdersService');
// Provide with the token
@Module({
providers: [
OrdersService,
{
provide: ORDERS_SERVICE,
useExisting: OrdersService,
},
],
exports: [ORDERS_SERVICE],
})
export class OrdersModule {}
// Inject using the token
@Injectable()
export class OrchestrationService {
constructor(
@Inject(ORDERS_SERVICE)
private readonly ordersService: IOrdersService
) {}
}
Common DI Error¶
If you see this error:
Nest can't resolve dependencies of the OrdersService (?).
Please make sure that the argument OrdersRepository at index [0]
is available in the OrdersModule context.
Solution: Make sure the provider is registered in the module, or import the module that exports it.
8. DTOs and Validation¶
DTOs (Data Transfer Objects) define the shape of request/response data and handle validation.
Request DTO with Validation¶
import { IsOptional, IsNumber, Min, IsEnum } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { OrderStatus } from '@prisma/client';
export class OrderQueryDto {
@ApiPropertyOptional({
description: 'Filter by order status',
enum: OrderStatus,
})
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus;
@ApiPropertyOptional({
description: 'Page number (1-based)',
minimum: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@ApiPropertyOptional({
description: 'Items per page',
minimum: 1,
maximum: 100,
default: 50,
})
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
pageSize?: number;
}
Response DTO with Swagger¶
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OrderStatus } from '@prisma/client';
export class OrderResponseDto {
@ApiProperty({
description: 'Unique identifier for the order',
example: '123e4567-e89b-12d3-a456-426614174000',
})
id!: string;
@ApiProperty({
description: 'Current order status',
enum: OrderStatus,
example: 'PENDING',
})
status!: OrderStatus;
@ApiProperty({
description: 'Customer name',
example: 'John Doe',
})
customerName!: string;
@ApiPropertyOptional({
description: 'Customer email address',
example: 'john@example.com',
nullable: true,
})
customerEmail!: string | null;
@ApiProperty({
description: 'Line items in this order',
type: [LineItemResponseDto],
})
lineItems!: LineItemResponseDto[];
}
Common Validators¶
| Decorator | Purpose | Example |
|---|---|---|
@IsString() |
Validate string | @IsString() name: string |
@IsNumber() |
Validate number | @IsNumber() age: number |
@IsEnum() |
Validate enum value | @IsEnum(Status) status: Status |
@IsOptional() |
Field is optional | @IsOptional() @IsString() name?: string |
@Min(), @Max() |
Number range | @Min(1) @Max(100) page: number |
@IsEmail() |
Validate email | @IsEmail() email: string |
@IsUUID() |
Validate UUID | @IsUUID() id: string |
@IsArray() |
Validate array | @IsArray() items: string[] |
Global Validation Pipe¶
Every microservice configures the global validation pipe in main.ts:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: false, // Tolerate extra properties (gateway may forward extra fields)
transform: true, // Auto-transform to DTO types
transformOptions: {
enableImplicitConversion: true,
},
})
);
9. Guards: Authentication & Authorization¶
Guards determine whether a request should be handled. They run before the controller method.
Session-Based Authentication¶
This project uses session-based auth with Redis-backed sessions, not API keys or JWTs.
How Sessions Work¶
- User sends email + password to
POST /api/v1/auth/login - Gateway validates credentials with argon2 password hashing
- On success, a session is created in Redis with user info (id, tenantId, email, roles, permissions)
- A session cookie (
forma3d.sid) is set in the browser - Subsequent requests include the cookie automatically
- The
SessionGuardvalidates the session on every request
Session Guard¶
From libs/service-common/src/lib/auth/guards/session.guard.ts:
@Injectable()
export class SessionGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
// Check for @Public() decorator
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
// Check session user (set by gateway proxy or direct session)
const user = request.currentUser || request.session?.user;
if (!user) {
throw new UnauthorizedException('Authentication required');
}
return true;
}
}
Permissions Guard (RBAC)¶
@Injectable()
export class PermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()]
);
if (!requiredPermissions) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request.currentUser || request.session?.user;
return requiredPermissions.every((perm) => user.permissions.includes(perm));
}
}
Using Guards¶
Both guards are registered globally via APP_GUARD in the root module:
@Module({
providers: [
{ provide: APP_GUARD, useClass: SessionGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
],
})
export class AppModule {}
To make an endpoint public (skip session check):
@Controller('api/v1/health')
export class HealthController {
@Get()
@Public() // Skips SessionGuard
check() {
return { status: 'ok' };
}
}
To require specific permissions:
@Controller('api/v1/orders')
export class OrdersController {
@Get()
@RequirePermissions('orders.read') // User must have this permission
findAll() {}
@Put(':id/cancel')
@RequirePermissions('orders.write')
cancel(@Param('id') id: string) {}
}
Auth Flow Through the Gateway¶
The gateway proxies requests to downstream services, forwarding user identity via internal headers. The UserContextMiddleware on each microservice extracts this and populates request.currentUser.
10. Events: Decoupled Communication¶
Events allow modules to communicate without direct dependencies. This project uses two event mechanisms:
- EventEmitter2 — in-process events within a single service
- BullMQ Event Bus — cross-service events via Redis (see next section)
Defining Events¶
import { OrderStatus } from '@prisma/client';
import { OrderEvent } from '@forma3d/domain';
export const ORDER_EVENTS = {
CREATED: 'order.created',
STATUS_CHANGED: 'order.status_changed',
CANCELLED: 'order.cancelled',
READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
} as const;
export class OrderCreatedEvent implements OrderEvent {
constructor(
public readonly correlationId: string,
public readonly timestamp: Date,
public readonly source: string,
public readonly orderId: string,
public readonly shopifyOrderId: string,
public readonly lineItemCount: number
) {}
static create(
correlationId: string,
orderId: string,
shopifyOrderId: string,
lineItemCount: number
): OrderCreatedEvent {
return new OrderCreatedEvent(
correlationId,
new Date(),
'orders',
orderId,
shopifyOrderId,
lineItemCount
);
}
}
Emitting and Listening¶
// Emitting from a service
this.eventEmitter.emit(
ORDER_EVENTS.CREATED,
OrderCreatedEvent.create(correlationId, order.id, shopifyOrderId, lineItemCount)
);
// Listening in another service
@OnEvent(ORDER_EVENTS.CREATED)
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
this.logger.log(`Processing new order: ${event.orderId}`);
await this.createPrintJobsForOrder(event.orderId);
}
Event Flow in Our System¶
11. BullMQ: Job Queues and Event Bus¶
BullMQ provides Redis-backed job queues for cross-service communication and reliable async processing.
Event Bus¶
From libs/service-common/src/lib/events/bullmq-event-bus.ts:
@Injectable()
export class BullMQEventBus implements IEventBus, OnModuleDestroy {
private readonly logger = new Logger(BullMQEventBus.name);
private readonly queues = new Map<string, Queue>();
private readonly workers = new Map<string, Worker>();
constructor(
@Inject('REDIS_CONNECTION') private readonly connection: ConnectionOptions,
) {}
async publish(event: ServiceEvent): Promise<void> {
const queue = this.getOrCreateQueue(event.eventType);
await queue.add(event.eventType, event, {
removeOnComplete: 1000,
removeOnFail: 5000,
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
}
async subscribe(eventType: string, handler: EventHandler): Promise<void> {
const worker = new Worker(
eventType,
async (job: Job<ServiceEvent>) => {
await handler(job.data);
},
{ connection: this.connection, concurrency: 5 },
);
this.workers.set(eventType, worker);
}
}
Key Concepts¶
- Queue: Each event type gets its own Redis-backed queue
- Worker: Each subscriber gets a worker that processes jobs concurrently
- Retry: Failed jobs are retried 3 times with exponential backoff
- Bull Board: Admin dashboard at
/admin/queuesfor monitoring queues, retrying failed jobs, and inspecting job data
Module Setup¶
@Module({
imports: [
EventBusModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
redisUrl: configService.get<string>('REDIS_URL') || 'redis://localhost:6379',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
12. WebSockets: Real-Time Communication¶
WebSockets enable real-time updates to connected clients (the React dashboard).
Gateway (WebSocket Handler)¶
import {
WebSocketGateway,
WebSocketServer,
OnGatewayInit,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { OnEvent } from '@nestjs/event-emitter';
import { Server, Socket } from 'socket.io';
@WebSocketGateway({
cors: {
origin: process.env.FRONTEND_URL || 'http://localhost:4200',
credentials: true,
},
namespace: '/events',
})
export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(EventsGateway.name);
@WebSocketServer()
server!: Server;
afterInit(): void {
this.logger.log('WebSocket Gateway initialized');
}
handleConnection(client: Socket): void {
this.logger.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`);
}
@OnEvent(ORDER_EVENTS.CREATED)
handleOrderCreated(event: OrderEventPayload): void {
this.server.emit('order:created', {
id: event.orderId,
orderNumber: event.shopifyOrderNumber,
});
}
@OnEvent(PRINT_JOB_EVENTS.FAILED)
handlePrintJobFailed(event: PrintJobEventPayload): void {
this.server.emit('printjob:failed', {
id: event.printJob.id,
error: event.errorMessage,
});
}
}
WebSocket connections use RedisIoAdapter for horizontal scaling — multiple service instances share socket state through Redis pub/sub.
13. Prisma: Database ORM¶
Prisma is our ORM for PostgreSQL. It provides type-safe database access.
Schema Definition¶
From prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum OrderStatus {
PENDING
PROCESSING
PARTIALLY_COMPLETED
COMPLETED
FAILED
CANCELLED
}
model Order {
id String @id @default(uuid())
tenantId String
shopifyOrderId String
shopifyOrderNumber String
status OrderStatus @default(PENDING)
customerName String
customerEmail String?
shippingAddress Json
totalPrice Decimal @db.Decimal(10, 2)
currency String @default("EUR")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
lineItems LineItem[]
@@unique([tenantId, shopifyOrderId])
@@index([tenantId, status])
@@index([tenantId, createdAt])
}
PrismaService¶
From libs/service-common/src/lib/database/prisma.service.ts:
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit(): Promise<void> {
await this.$connect();
this.logger.log('Database connection established');
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
this.logger.log('Database connection closed');
}
async isHealthy(): Promise<boolean> {
try {
await this.$queryRaw`SELECT 1`;
return true;
} catch {
return false;
}
}
}
Common Prisma Operations¶
// Find one
const order = await this.prisma.order.findUnique({
where: { id },
include: { lineItems: true },
});
// Find many with filters and multi-tenancy
const orders = await this.prisma.order.findMany({
where: { tenantId, status: 'PENDING' },
orderBy: { createdAt: 'desc' },
skip: 0,
take: 10,
});
// Create with relations
const order = await this.prisma.order.create({
data: {
tenantId,
customerName: 'John',
lineItems: {
create: [
{ tenantId, productSku: 'SKU-001', quantity: 2 },
{ tenantId, productSku: 'SKU-002', quantity: 1 },
],
},
},
include: { lineItems: true },
});
// Transaction
await this.prisma.$transaction([
this.prisma.order.update({ where: { id }, data: { status: 'CANCELLED' } }),
this.prisma.printJob.updateMany({
where: { orderId: id },
data: { status: 'CANCELLED' },
}),
]);
Database Migrations¶
# Create migration after schema change
pnpm prisma migrate dev --name add_tracking_fields
# Apply migrations
pnpm prisma migrate deploy
# Generate Prisma client
pnpm prisma generate
# View database
pnpm prisma studio
14. Multi-Tenancy¶
Every entity in the system belongs to a tenant. The TenantContextService resolves the current tenant from the session.
Tenant Context Service¶
From libs/service-common/src/lib/tenancy/tenant-context.service.ts:
@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {
private readonly _tenantId: string;
constructor(@Inject(REQUEST) private readonly request: RequestWithSession) {
this._tenantId = this.resolveTenantId();
}
get tenantId(): string {
return this._tenantId;
}
get currentUser(): SessionUser | undefined {
return this.request.currentUser || this.request.session?.user;
}
private resolveTenantId(): string {
const user = this.request.currentUser || this.request.session?.user;
if (user?.tenantId) {
return user.tenantId;
}
return DEFAULT_TENANT_ID;
}
}
Key points:
- Request-scoped (
Scope.REQUEST) — a new instance per HTTP request - Resolves tenant from the authenticated user's session
- Falls back to
DEFAULT_TENANT_IDwhen no user context is available - Repositories always filter by
tenantIdto enforce data isolation
15. Configuration Management¶
Configuration is centralized using @nestjs/config with typed configuration objects.
Configuration File¶
From apps/order-service/src/config/configuration.ts:
export default () => ({
app: {
nodeEnv: process.env['NODE_ENV'] || 'development',
port: parseInt(process.env['APP_PORT'] || '3001', 10),
},
database: {
url: process.env['DATABASE_URL'] || '',
},
shopify: {
apiKey: process.env['SHOPIFY_API_KEY'] || '',
apiSecret: process.env['SHOPIFY_API_SECRET'] || '',
accessToken: process.env['SHOPIFY_ACCESS_TOKEN'] || '',
},
simplyPrint: {
apiUrl: process.env['SIMPLYPRINT_API_URL'] || 'https://api.simplyprint.io',
apiKey: process.env['SIMPLYPRINT_API_KEY'] || '',
},
storefront: {
allowedOrigins: (process.env['STOREFRONT_ALLOWED_ORIGINS'] || '.myshopify.com')
.split(',')
.map((o: string) => o.trim())
.filter(Boolean),
},
});
Using Configuration¶
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ShopifyService {
private readonly apiKey: string;
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<string>('shopify.apiKey', '');
}
}
16. Observability: Logging, Tracing, and Error Tracking¶
Pino Structured Logging¶
All services use Pino for structured JSON logging:
import { Logger } from '@nestjs/common';
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
async createOrder(input: CreateOrderInput): Promise<Order> {
this.logger.log(`Creating order from Shopify: ${input.shopifyOrderId}`);
// ...
this.logger.warn(`Order ${id} has missing line items`);
this.logger.error(`Failed to process order ${id}`, error.stack);
}
}
Sentry Integration¶
Every service has a ./observability/instrument.ts file imported as the first import in main.ts:
// MUST be first import - Sentry instrumentation
import './observability/instrument';
Sentry captures:
- Unhandled exceptions and rejections
- Performance traces (configurable sample rate)
- User context (set on login, cleared on logout)
- Correlation IDs for distributed tracing
Audit Logging¶
Security and compliance events are logged to a dedicated AuditLog PostgreSQL table:
await this.auditService.log({
action: 'auth.login',
userId: user.id,
tenantId: user.tenantId,
metadata: { email: user.email },
});
This is separate from EventLogService (business events) and Pino (operational logs).
17. Testing NestJS Applications¶
We use Jest for backend testing with NestJS's testing utilities.
Unit Test Structure¶
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from '../orders.service';
import { OrdersRepository } from '../orders.repository';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { OrderStatus } from '@prisma/client';
describe('OrdersService', () => {
let service: OrdersService;
let repository: jest.Mocked<OrdersRepository>;
let eventEmitter: jest.Mocked<EventEmitter2>;
const mockOrder = {
id: 'order-uuid-1',
tenantId: '00000000-0000-0000-0000-000000000001',
shopifyOrderId: '123456789',
status: OrderStatus.PENDING,
customerName: 'John Doe',
lineItems: [],
};
beforeEach(async () => {
const mockRepository = {
create: jest.fn(),
findById: jest.fn(),
findByShopifyOrderId: jest.fn(),
updateStatus: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: OrdersRepository, useValue: mockRepository },
{ provide: EventEmitter2, useValue: mockEventEmitter },
],
}).compile();
service = module.get<OrdersService>(OrdersService);
repository = module.get(OrdersRepository);
eventEmitter = module.get(EventEmitter2);
});
describe('findById', () => {
it('should return order when found', async () => {
repository.findById.mockResolvedValue(mockOrder);
const result = await service.findById('order-uuid-1');
expect(result).toEqual(mockOrder);
});
it('should throw NotFoundException when order not found', async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById('non-existent')).rejects.toThrow(NotFoundException);
});
});
describe('cancelOrder', () => {
it('should throw ConflictException when cancelling completed order', async () => {
repository.findById.mockResolvedValue({
...mockOrder,
status: OrderStatus.COMPLETED,
});
await expect(service.cancelOrder('order-uuid-1')).rejects.toThrow(ConflictException);
});
});
});
Testing Patterns¶
// 1. Always mock all dependencies
const mockRepository = { findById: jest.fn(), create: jest.fn() };
// 2. Reset mocks between tests
beforeEach(() => { jest.clearAllMocks(); });
// 3. Test async operations with expect().rejects
await expect(service.findById('bad-id')).rejects.toThrow(NotFoundException);
// 4. Verify mock calls
expect(repository.create).toHaveBeenCalledWith(expect.objectContaining({ customerName: 'John' }));
18. Microservice Architecture¶
This project is not a monolith. It's a set of microservices that communicate through the gateway and Redis.
Services¶
| Service | Port | Responsibility |
|---|---|---|
| Gateway | 3000 | Session auth, HTTP/WebSocket proxy, rate limiting, Bull Board |
| Order Service | 3001 | Orders, Shopify webhooks, product mappings, orchestration |
| Print Service | 3002 | Print jobs, SimplyPrint integration |
| Shipping Service | 3003 | Shipments, Sendcloud integration |
| GridFlock Service | 3004 | Parametric 3D model generation, slicing pipeline |
| Slicer | 3010 | BambuStudio CLI wrapper (Express, not NestJS) |
Communication Patterns¶
Inter-Service HTTP Clients¶
Services communicate via typed HTTP clients from libs/service-common:
// SlicerClient — sends STL to slicer, gets 3MF back
await this.slicerClient.slice({ stlBuffer, machineProfile, processProfile, filamentProfile });
// PrintServiceClient — uploads files to SimplyPrint
await this.printServiceClient.uploadFileToSimplyPrint(fileBuffer, filename, folderId);
// OrderServiceClient — creates product mappings
await this.orderServiceClient.createProductMapping(sku, simplyPrintFileId);
Shared Library¶
libs/service-common (@forma3d/service-common) provides cross-cutting concerns used by every microservice:
- DatabaseModule — PrismaService
- EventBusModule — BullMQ event bus
- SharedAuthModule — SessionGuard, PermissionsGuard, UserContextMiddleware
- TenancyModule — TenantContextService
- AuditModule — AuditService, AuditRepository
- EventLogModule — EventLogService (business event history)
- ServiceClientModule — HTTP clients for inter-service calls
- SharedObservabilityModule — Sentry, Pino, OpenTelemetry
19. Project Structure and Conventions¶
Microservice Structure¶
apps/order-service/src/
├── main.ts # Entry point, bootstrap
├── observability/
│ └── instrument.ts # Sentry (MUST be first import)
├── app/
│ └── app.module.ts # Root module
├── config/
│ ├── config.module.ts
│ └── configuration.ts
├── orders/ # Feature module
│ ├── orders.module.ts
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ ├── orders.repository.ts
│ ├── dto/
│ │ ├── order.dto.ts
│ │ ├── order-query.dto.ts
│ │ └── create-order.dto.ts
│ ├── events/
│ │ └── order.events.ts
│ └── __tests__/
│ └── orders.service.spec.ts
├── orchestration/ # Cross-module coordination
│ ├── orchestration.module.ts
│ └── orchestration.service.ts
├── shopify/ # Shopify webhook handling
│ └── ...
├── storefront/ # Public storefront endpoints
│ └── ...
├── retry-queue/ # BullMQ retry logic
│ └── retry-queue.processor.ts
└── adapters/
└── redis-io.adapter.ts # Socket.IO Redis adapter
Shared Libraries¶
libs/
├── service-common/ # Cross-cutting concerns (auth, database, events, clients)
├── domain/ # Shared entities, enums, Zod schemas, typed errors
├── domain-contracts/ # DTOs and interfaces for cross-service contracts
├── api-client/ # HTTP clients for external APIs (SimplyPrint, Sendcloud)
├── gridflock-core/ # JSCAD-based 3D model generation library
├── config/ # Shared configuration, env validation
├── utils/ # Date, string, encryption utilities
├── observability/ # Sentry instrumentation
└── testing/ # Test utilities
Naming Conventions¶
| Type | Convention | Example |
|---|---|---|
| Modules | *.module.ts |
orders.module.ts |
| Controllers | *.controller.ts |
orders.controller.ts |
| Services | *.service.ts |
orders.service.ts |
| Repositories | *.repository.ts |
orders.repository.ts |
| DTOs | *.dto.ts |
create-order.dto.ts |
| Events | *.events.ts |
order.events.ts |
| Guards | *.guard.ts |
session.guard.ts |
| Tests | *.spec.ts |
orders.service.spec.ts |
Project Rules¶
- Controllers handle HTTP only — no business logic
- Services handle business logic only — no HTTP, no direct DB
- Repositories handle database only — Prisma stays here
- DTOs required for all I/O — validate and transform
- Typed errors only — use
NotFoundException,ConflictException, etc. - No silent failures — always log or throw
- Always scope by tenantId — multi-tenancy is enforced at every layer
20. Common Patterns in This Codebase¶
Pattern 1: Feature Module Structure¶
orders/
├── orders.module.ts # Wires everything together
├── orders.controller.ts # HTTP endpoints
├── orders.service.ts # Business logic
├── orders.repository.ts # Database access
├── dto/ # Data shapes
│ ├── order.dto.ts
│ └── create-order.dto.ts
├── events/ # Domain events
│ └── order.events.ts
└── __tests__/ # Unit tests
└── orders.service.spec.ts
Pattern 2: Layered Architecture Flow¶
// Controller: HTTP → Service
@Controller('api/v1/orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
@RequirePermissions('orders.write')
async create(@Body() dto: CreateOrderDto) {
return this.ordersService.create(dto);
}
}
// Service: Business Logic → Repository + Events
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly eventEmitter: EventEmitter2,
private readonly eventLogService: EventLogService
) {}
async create(input: CreateOrderInput): Promise<Order> {
const order = await this.ordersRepository.create(input);
await this.eventLogService.log({ orderId: order.id, eventType: 'order.created', severity: 'INFO', message: 'Order created' });
this.eventEmitter.emit('order.created', { orderId: order.id });
return order;
}
}
// Repository: Database operations only
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
async create(input: CreateOrderInput): Promise<Order> {
return this.prisma.order.create({ data: { tenantId: input.tenantId, ...input } });
}
}
Pattern 3: Event-Driven Cross-Module Communication¶
// Module A emits (in-process)
this.eventEmitter.emit(ORDER_EVENTS.CREATED, event);
// Module B listens (in-process)
@OnEvent(ORDER_EVENTS.CREATED)
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.createPrintJobs(event.orderId);
}
// Cross-service via BullMQ
await this.eventBus.publish({
eventType: SERVICE_EVENTS.PRINT_JOB_COMPLETED,
data: { orderId, printJobId },
});
Pattern 4: Interface-Based DI for Cross-Service Contracts¶
// Define interface in @forma3d/domain-contracts
export interface IOrdersService {
findById(id: string): Promise<OrderDto | null>;
updateStatus(id: string, status: OrderStatus): Promise<void>;
}
export const ORDERS_SERVICE = Symbol('IOrdersService');
// Implement in service
@Injectable()
export class OrdersService implements IOrdersService { /* ... */ }
// Register in module
{ provide: ORDERS_SERVICE, useExisting: OrdersService }
// Inject in consuming module
@Inject(ORDERS_SERVICE) private readonly ordersService: IOrdersService
21. Quick Reference¶
Essential Imports¶
// NestJS Core
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common';
import { Module } from '@nestjs/common';
// Validation
import { IsString, IsNumber, IsOptional, IsEnum, Min } from 'class-validator';
import { Type } from 'class-transformer';
// Swagger
import { ApiTags, ApiOperation, ApiResponse, ApiProperty } from '@nestjs/swagger';
// Events
import { EventEmitter2 } from '@nestjs/event-emitter';
import { OnEvent } from '@nestjs/event-emitter';
// Auth & Permissions
import { RequirePermissions, Public, SessionGuard, PermissionsGuard } from '@forma3d/service-common';
// Config
import { ConfigService } from '@nestjs/config';
// Database
import { PrismaService } from '@forma3d/service-common';
import { OrderStatus } from '@prisma/client';
// Testing
import { Test, TestingModule } from '@nestjs/testing';
Cheat Sheet¶
| What you want | How to do it |
|---|---|
| Create a service | @Injectable() export class MyService {} |
| Handle GET request | @Get() findAll() {} |
| Get URL parameter | @Param('id') id: string |
| Get query params | @Query() query: QueryDto |
| Get request body | @Body() dto: CreateDto |
| Require session | Global SessionGuard (automatic) |
| Skip auth on endpoint | @Public() |
| Require permission | @RequirePermissions('orders.read') |
| Emit an event | this.eventEmitter.emit('name', payload) |
| Listen to event | @OnEvent('name') handle(payload) {} |
| Publish cross-service | this.eventBus.publish({ eventType, data }) |
| Throw 404 | throw new NotFoundException('Not found') |
| Throw 409 | throw new ConflictException('Conflict') |
| Log message | this.logger.log('message') |
| Get config value | this.configService.get<string>('key') |
| Get current tenant | this.tenantContextService.tenantId |
Running the Services¶
# Start a specific service
pnpm nx serve order-service
pnpm nx serve gateway
# Run tests for a service
pnpm nx test order-service
# Run all backend tests
pnpm nx run-many -t test --projects=gateway,order-service,print-service,shipping-service,gridflock-service
# Generate Prisma client
pnpm prisma generate
# Run migrations
pnpm prisma migrate dev
# View API docs (gateway must be running)
open http://localhost:3000/api/docs
# View Bull Board queue dashboard (requires admin session)
open http://localhost:3000/admin/queues
Next Steps¶
- Explore a service: Start with
apps/order-service/src/orders/to see a complete feature module - Trace a request: Follow a Shopify webhook from gateway → proxy → controller → service → repository
- Study events: See how
OrchestrationServicecoordinates cross-module logic - Understand shared code: Browse
libs/service-common/to see guards, middleware, and clients - Run the tests:
pnpm nx test order-serviceto see testing patterns - Use Swagger: Visit
/api/docson the gateway to explore and test all endpoints