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
- WebSockets: Real-Time Communication
- Prisma: Database ORM
- Configuration Management
- Testing NestJS Applications
- 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 (or Fastify) 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 |
Mental Model¶
Think of NestJS as having three main layers:
HTTP Request
↓
┌─────────────────────┐
│ Controller │ ← Handles HTTP, validates input
│ (HTTP Layer) │
└─────────────────────┘
↓
┌─────────────────────┐
│ Service │ ← Business logic, domain rules
│ (Business Layer) │
└─────────────────────┘
↓
┌─────────────────────┐
│ Repository │ ← Database operations (Prisma)
│ (Data Layer) │
└─────────────────────┘
↓
Database
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(ApiKeyGuard) |
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/api/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 } from '../event-log/event-log.module';
import { ORDERS_SERVICE } from '@forma3d/domain-contracts';
@Module({
imports: [EventLogModule],
controllers: [OrdersController],
providers: [
OrdersService,
OrdersRepository,
{
provide: ORDERS_SERVICE,
useExisting: OrdersService,
},
],
exports: [OrdersService, ORDERS_SERVICE],
})
export class OrdersModule {}
Key concepts:
- imports: Brings in EventLogModule so we can use EventLogService
- providers: Registers services for dependency injection
- exports: Makes OrdersService available to other modules
The Root Module¶
From apps/api/src/app/app.module.ts:
@Module({
imports: [
// Infrastructure & Cross-Cutting
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
ConfigModule,
DatabaseModule,
// Core Domain
OrdersModule,
ProductMappingsModule,
PrintJobsModule,
// Integration
SimplyPrintModule,
SendcloudModule,
// API
GatewayModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CorrelationMiddleware).forRoutes('*');
}
}
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 Swagger Documentation¶
From apps/api/src/orders/orders.controller.ts:
import {
Controller,
Get,
Param,
Query,
Put,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
} from '@nestjs/swagger';
@ApiTags('Orders') // Groups endpoints in Swagger UI
@Controller('api/v1/orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
@ApiOperation({
summary: 'List orders',
description: 'Retrieve a paginated list of orders.',
})
@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)
@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 helper to convert entities to DTOs
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
- 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() // Required for dependency injection
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly eventEmitter: EventEmitter2,
) {}
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);
this.eventEmitter.emit('order.created', { orderId: order.id });
return order;
}
}
Real Example with Full Business Logic¶
From apps/api/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> {
// Check for existing order (idempotency)
const existing = await this.ordersRepository.findByShopifyOrderId(
input.shopifyOrderId
);
if (existing) {
this.logger.debug(
`Order ${input.shopifyOrderId} already exists, skipping creation`
);
return existing;
}
// Create new order
const order = await this.ordersRepository.create(input);
this.logger.log(
`Created order ${order.id} from Shopify order ${input.shopifyOrderId}`
);
// Log event for audit trail
await this.eventLogService.log({
orderId: order.id,
eventType: 'order.created',
severity: 'INFO',
message: `Order created from Shopify webhook`,
metadata: { webhookId, shopifyOrderNumber: input.shopifyOrderNumber },
});
// Emit event for downstream processing
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);
// Business rule: completed orders cannot be cancelled
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]: [], // Terminal state
[OrderStatus.CANCELLED]: [], // Terminal state
// ...
};
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 - with appropriate log levels
- Throw typed exceptions -
NotFoundException,ConflictException, etc.
6. Repositories: Database Access¶
Repositories encapsulate all database operations. This keeps Prisma usage isolated and makes testing easier.
Basic Repository¶
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../database/prisma.service';
import { Order, OrderStatus } from '@prisma/client';
@Injectable()
export class OrdersRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<Order | null> {
return this.prisma.order.findUnique({
where: { id },
include: { lineItems: true },
});
}
async findAll(params: {
status?: OrderStatus;
skip?: number;
take?: number;
}): Promise<{ orders: Order[]; total: number }> {
const where = 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 create(input: CreateOrderInput): Promise<Order> {
return this.prisma.order.create({
data: {
shopifyOrderId: input.shopifyOrderId,
customerName: input.customerName,
// ... other fields
lineItems: {
create: input.lineItems.map((item) => ({
productSku: item.productSku,
quantity: item.quantity,
// ...
})),
},
},
include: { lineItems: true },
});
}
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
- Define input/output types - clear interfaces for data shapes
- Use Prisma types - leverage generated types
- Handle relations - use
includefor eager loading
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: 1. Creates a single instance of each provider (singleton by default) 2. Injects dependencies when creating instances 3. Manages the lifecycle (init, destroy)
Token-Based Injection¶
Sometimes you need to inject by token instead of class:
// Define a token
export const ORDERS_SERVICE = Symbol('IOrdersService');
// Provide with the token
@Module({
providers: [
OrdersService,
{
provide: ORDERS_SERVICE,
useExisting: OrdersService, // Use the same instance
},
],
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) // Transform string to 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¶
From apps/api/src/main.ts:
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw error on unknown properties
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.
API Key Guard¶
From apps/api/src/common/guards/api-key.guard.ts:
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as crypto from 'crypto';
@Injectable()
export class ApiKeyGuard implements CanActivate {
private readonly apiKey: string;
private readonly isEnabled: boolean;
constructor(private readonly configService: ConfigService) {
this.apiKey = this.configService.get<string>('INTERNAL_API_KEY', '');
this.isEnabled = this.apiKey.length > 0;
}
canActivate(context: ExecutionContext): boolean {
// If not configured, allow (development mode)
if (!this.isEnabled) {
return true;
}
const request = context.switchToHttp().getRequest<Request>();
const providedKey = request.headers['x-api-key'] as string;
if (!providedKey) {
throw new UnauthorizedException('API key required');
}
// Timing-safe comparison to prevent timing attacks
const isValid = this.timingSafeEqual(providedKey, this.apiKey);
if (!isValid) {
throw new UnauthorizedException('Invalid API key');
}
return true;
}
private timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
}
Using Guards¶
import { UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../common/guards';
@Controller('api/v1/admin')
@UseGuards(ApiKeyGuard) // Protect entire controller
export class AdminController {
@Get('stats')
getStats() {
// Only accessible with valid API key
}
}
// Or protect individual endpoints
@Controller('api/v1/orders')
export class OrdersController {
@Get() // Public
findAll() {}
@Put(':id/cancel')
@UseGuards(ApiKeyGuard) // Protected
cancel(@Param('id') id: string) {}
}
10. Events: Decoupled Communication¶
Events allow modules to communicate without direct dependencies. This is essential for loose coupling.
Defining Events¶
From apps/api/src/orders/events/order.events.ts:
import { OrderStatus } from '@prisma/client';
import { OrderEvent } from '@forma3d/domain';
// Event name constants
export const ORDER_EVENTS = {
CREATED: 'order.created',
STATUS_CHANGED: 'order.status_changed',
CANCELLED: 'order.cancelled',
READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
} as const;
// Event class with factory method
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 Events¶
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class OrdersService {
constructor(private readonly eventEmitter: EventEmitter2) {}
async createOrder(input: CreateOrderInput): Promise<Order> {
const order = await this.ordersRepository.create(input);
// Emit event for other modules to react
this.eventEmitter.emit(
ORDER_EVENTS.CREATED,
OrderCreatedEvent.create(
this.correlationService.getOrCreateCorrelationId(),
order.id,
input.shopifyOrderId,
order.lineItems.length
)
);
return order;
}
}
Listening to Events¶
import { OnEvent } from '@nestjs/event-emitter';
@Injectable()
export class OrchestrationService {
@OnEvent(ORDER_EVENTS.CREATED)
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
this.logger.log(`Processing new order: ${event.orderId}`);
// Create print jobs for each line item
await this.createPrintJobsForOrder(event.orderId);
// Update order status
await this.ordersService.updateStatus(event.orderId, OrderStatus.PROCESSING);
}
@OnEvent(PRINT_JOB_EVENTS.COMPLETED)
async handlePrintJobCompleted(event: PrintJobCompletedEvent): Promise<void> {
// Check if all jobs for order are done
await this.checkOrderCompletion(event.orderId);
}
}
Event Flow in Our System¶
Shopify Webhook
↓
OrdersService.createFromShopify()
↓ emit
ORDER_EVENTS.CREATED
↓ listen
OrchestrationService.handleOrderCreated()
↓ creates print jobs
PRINT_JOB_EVENTS.CREATED
↓ listen
EventsGateway → WebSocket → Frontend
11. WebSockets: Real-Time Communication¶
WebSockets enable real-time updates to connected clients.
Gateway (WebSocket Handler)¶
From apps/api/src/gateway/events.gateway.ts:
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}`);
}
// Listen to internal events and broadcast to clients
@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,
});
}
// Helper method for custom notifications
sendNotification(type: 'info' | 'warning' | 'error', message: string): void {
this.server.emit('notification', {
type,
message,
timestamp: new Date().toISOString(),
});
}
}
12. 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())
shopifyOrderId String @unique
shopifyOrderNumber String
status OrderStatus @default(PENDING)
customerName String
customerEmail String?
shippingAddress Json
totalPrice Decimal @db.Decimal(10, 2)
currency String @default("EUR")
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
// Relations
lineItems LineItem[]
@@index([status])
@@index([createdAt])
}
model LineItem {
id String @id @default(uuid())
orderId String
productSku String
quantity Int
// Relations
order Order @relation(fields: [orderId], references: [id])
printJobs PrintJob[]
}
PrismaService¶
From apps/api/src/database/prisma.service.ts:
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@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
const orders = await this.prisma.order.findMany({
where: { status: 'PENDING' },
orderBy: { createdAt: 'desc' },
skip: 0,
take: 10,
});
// Create with relations
const order = await this.prisma.order.create({
data: {
customerName: 'John',
lineItems: {
create: [
{ productSku: 'SKU-001', quantity: 2 },
{ productSku: 'SKU-002', quantity: 1 },
],
},
},
include: { lineItems: true },
});
// Update
const order = await this.prisma.order.update({
where: { id },
data: { status: 'COMPLETED', completedAt: new Date() },
});
// Count
const count = await this.prisma.order.count({
where: { status: 'PENDING' },
});
// 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
13. Configuration Management¶
Configuration is centralized using @nestjs/config.
Configuration File¶
From apps/api/src/config/configuration.ts:
export interface Configuration {
app: {
nodeEnv: string;
port: number;
appUrl: string;
frontendUrl: string;
};
database: {
url: string;
};
shopify: {
apiKey: string;
apiSecret: string;
accessToken: string;
};
simplyPrint: {
apiUrl: string;
apiKey: string;
};
}
export default (): Configuration => ({
app: {
nodeEnv: process.env['NODE_ENV'] || 'development',
port: parseInt(process.env['APP_PORT'] || '3000', 10),
appUrl: process.env['APP_URL'] || 'http://localhost:3000',
frontendUrl: process.env['FRONTEND_URL'] || 'http://localhost:4200',
},
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/v1',
apiKey: process.env['SIMPLYPRINT_API_KEY'] || '',
},
});
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', '');
}
// Or access directly
async callApi() {
const baseUrl = this.configService.get<string>('shopify.shopDomain');
// ...
}
}
14. Testing NestJS Applications¶
We use Jest for backend testing with NestJS's testing utilities.
Unit Test Structure¶
From apps/api/src/orders/__tests__/orders.service.spec.ts:
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>;
// Mock data
const mockOrder = {
id: 'order-uuid-1',
shopifyOrderId: '123456789',
status: OrderStatus.PENDING,
customerName: 'John Doe',
lineItems: [],
};
beforeEach(async () => {
// Create mocks
const mockRepository = {
create: jest.fn(),
findById: jest.fn(),
findByShopifyOrderId: jest.fn(),
updateStatus: jest.fn(),
};
const mockEventEmitter = {
emit: jest.fn(),
};
// Build test module
const module: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: OrdersRepository, useValue: mockRepository },
{ provide: EventEmitter2, useValue: mockEventEmitter },
// ... other dependencies
],
}).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);
expect(repository.findById).toHaveBeenCalledWith('order-uuid-1');
});
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 cancel a pending order', async () => {
repository.findById.mockResolvedValue(mockOrder);
repository.updateStatus.mockResolvedValue({
...mockOrder,
status: OrderStatus.CANCELLED,
});
await service.cancelOrder('order-uuid-1');
expect(repository.updateStatus).toHaveBeenCalledWith(
'order-uuid-1',
OrderStatus.CANCELLED
);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'order.cancelled',
expect.any(Object)
);
});
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. Test module compilation (catches DI errors)
describe('OrdersModule', () => {
it('should compile the module', async () => {
const module = await Test.createTestingModule({
imports: [OrdersModule, DatabaseModule],
}).compile();
expect(module.get(OrdersService)).toBeDefined();
});
});
// 2. Mock all dependencies
const mockRepository = {
findById: jest.fn(),
create: jest.fn(),
};
// 3. Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
// 4. Test async operations with expect().rejects
await expect(service.findById('bad-id')).rejects.toThrow(NotFoundException);
// 5. Verify mock calls
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({ customerName: 'John' })
);
15. Project Structure and Conventions¶
Directory Structure¶
apps/api/src/
├── main.ts # Entry point, bootstrap
├── app/
│ ├── app.module.ts # Root module
│ ├── app.controller.ts
│ └── app.service.ts
├── common/ # Shared utilities
│ ├── guards/
│ │ └── api-key.guard.ts
│ └── correlation/
│ └── correlation.service.ts
├── config/
│ ├── config.module.ts
│ ├── configuration.ts
│ └── env.validation.ts
├── database/
│ ├── database.module.ts
│ └── prisma.service.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
├── print-jobs/ # Another feature module
│ └── ... (same structure)
├── gateway/
│ ├── gateway.module.ts
│ └── events.gateway.ts
├── health/
│ ├── health.module.ts
│ └── health.controller.ts
└── observability/
├── observability.module.ts
└── instrument.ts
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 |
api-key.guard.ts |
| Tests | *.spec.ts |
orders.service.spec.ts |
Project Rules (from .cursorrules)¶
- 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
16. 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()
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,
) {}
async create(input: CreateOrderInput): Promise<Order> {
const order = await this.ordersRepository.create(input);
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: input });
}
}
Pattern 3: Event-Driven Cross-Module Communication¶
// Module A emits
this.eventEmitter.emit(ORDER_EVENTS.CREATED, event);
// Module B listens
@OnEvent(ORDER_EVENTS.CREATED)
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.createPrintJobs(event.orderId);
}
Pattern 4: Interface-Based DI for Cross-Domain¶
// Define interface in shared library
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
17. 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';
// Config
import { ConfigService } from '@nestjs/config';
// Database
import { PrismaService } from '../database/prisma.service';
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 |
| Protect endpoint | @UseGuards(ApiKeyGuard) |
| Emit an event | this.eventEmitter.emit('name', payload) |
| Listen to event | @OnEvent('name') handle(payload) {} |
| 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') |
Running the API¶
# Start development server
pnpm nx serve api
# Run tests
pnpm nx test api
# Run e2e tests
pnpm nx e2e api-e2e
# Generate Prisma client
pnpm prisma generate
# Run migrations
pnpm prisma migrate dev
# View API docs
open http://localhost:3000/api/docs
Next Steps¶
- Explore the modules: Start with
apps/api/src/orders/to see a complete feature - Trace a request: Follow a webhook from controller → service → repository
- Study events: See how
OrchestrationServicecoordinates cross-module logic - Run the tests:
pnpm nx test apito see testing patterns - Use Swagger: Visit
/api/docsto explore and test endpoints - Make small changes: Try adding a new field or endpoint