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. BullMQ: Job Queues and Event Bus
  12. WebSockets: Real-Time Communication
  13. Prisma: Database ORM
  14. Multi-Tenancy
  15. Configuration Management
  16. Observability: Logging, Tracing, and Error Tracking
  17. Testing NestJS Applications
  18. Microservice Architecture
  19. Project Structure and Conventions
  20. Common Patterns in This Codebase
  21. 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:

uml diagram


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 OrdersService available to other modules
  • Token-based provide: Registers OrdersService under the ORDERS_SERVICE token 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_GUARD registers guards globally — every endpoint gets session + permission checks
  • APP_INTERCEPTOR registers interceptors globally — API versioning, deprecation headers
  • Middleware runs on every request — security headers, user context extraction, correlation IDs
  • Shared modules from @forma3d/service-common provide 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)

  1. Handle HTTP only — no business logic
  2. Validate input — use DTOs with class-validator
  3. Transform output — convert entities to DTOs
  4. Declare permissions — use @RequirePermissions() for RBAC
  5. 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)

  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 — use EventLogService for auditable business events, Logger for operational logs
  5. Throw typed exceptionsNotFoundException, ConflictException, etc.
  6. 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)

  1. Database operations only — no business logic
  2. Always include tenantId — multi-tenancy is enforced at the data layer
  3. Use Prisma types — leverage generated types from the schema
  4. Handle relations — use include for eager loading
  5. 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:

  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

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

  1. User sends email + password to POST /api/v1/auth/login
  2. Gateway validates credentials with argon2 password hashing
  3. On success, a session is created in Redis with user info (id, tenantId, email, roles, permissions)
  4. A session cookie (forma3d.sid) is set in the browser
  5. Subsequent requests include the cookie automatically
  6. The SessionGuard validates 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

uml diagram

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:

  1. EventEmitter2 — in-process events within a single service
  2. 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

uml diagram


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/queues for 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_ID when no user context is available
  • Repositories always filter by tenantId to 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

uml diagram

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

  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
  7. 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

  1. Explore a service: Start with apps/order-service/src/orders/ to see a complete feature module
  2. Trace a request: Follow a Shopify webhook from gateway → proxy → controller → service → repository
  3. Study events: See how OrchestrationService coordinates cross-module logic
  4. Understand shared code: Browse libs/service-common/ to see guards, middleware, and clients
  5. Run the tests: pnpm nx test order-service to see testing patterns
  6. Use Swagger: Visit /api/docs on the gateway to explore and test all endpoints

Resources