Skip to content

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

  1. What is NestJS?
  2. Core Concepts
  3. Modules: Organizing Your Application
  4. Controllers: Handling HTTP Requests
  5. Services: Business Logic
  6. Repositories: Database Access
  7. Dependency Injection
  8. DTOs and Validation
  9. Guards: Authentication & Authorization
  10. Events: Decoupled Communication
  11. WebSockets: Real-Time Communication
  12. Prisma: Database ORM
  13. Configuration Management
  14. Testing NestJS Applications
  15. Project Structure and Conventions
  16. Common Patterns in This Codebase
  17. 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)

  1. Handle HTTP only - no business logic
  2. Validate input - use DTOs with class-validator
  3. Transform output - convert entities to DTOs
  4. 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)

  1. Business logic only - no HTTP concerns, no direct database queries
  2. Use repository for data access - never call Prisma directly
  3. Emit events - for cross-module communication
  4. Log significant operations - with appropriate log levels
  5. 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)

  1. Database operations only - no business logic
  2. Define input/output types - clear interfaces for data shapes
  3. Use Prisma types - leverage generated types
  4. Handle relations - use include for 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)

  1. Controllers handle HTTP only - no business logic
  2. Services handle business logic only - no HTTP, no direct DB
  3. Repositories handle database only - Prisma stays here
  4. DTOs required for all I/O - validate and transform
  5. Typed errors only - use NotFoundException, ConflictException, etc.
  6. 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

  1. Explore the modules: Start with apps/api/src/orders/ to see a complete feature
  2. Trace a request: Follow a webhook from controller → service → repository
  3. Study events: See how OrchestrationService coordinates cross-module logic
  4. Run the tests: pnpm nx test api to see testing patterns
  5. Use Swagger: Visit /api/docs to explore and test endpoints
  6. Make small changes: Try adding a new field or endpoint

Resources