Skip to content

Clean Architecture in NestJS Modules

Implementing layered architecture within NestJS modules

Implements: Clean Architecture | Use Cases | Dependency Inversion

Overview

Each NestJS module in our applications follows Clean Architecture principles, organizing code into three distinct layers: domain, use cases, and infrastructure. This separation ensures business logic remains independent of frameworks and technical details.

The Three Layers

Layer Structure

module/
├── domain/          # Core business logic
├── use-cases/       # Application workflows  
└── infrastructure/  # Framework & external concerns

The Dependency Rule

Dependencies always point inward:

  • Infrastructure → Use Cases → Domain
  • Domain knows nothing about other layers
  • Use Cases know domain but not infrastructure
  • Infrastructure knows everything but is known by nothing

Domain Layer

The heart of your module—pure business logic with no framework dependencies.

Entities

Domain entities encapsulate business rules and behaviors:

typescript
// domain/order.entity.ts
export class Order {
  private constructor(
    private readonly id: OrderId,
    private customerId: CustomerId,
    private items: OrderItem[],
    private status: OrderStatus,
    private total: Money,
  ) {}

  static create(customerId: CustomerId, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new EmptyOrderError();
    }

    const total = this.calculateTotal(items);
    
    return new Order(
      OrderId.generate(),
      customerId,
      items,
      OrderStatus.PENDING,
      total,
    );
  }

  addItem(item: OrderItem): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new OrderNotModifiableError();
    }
    
    this.items.push(item);
    this.total = Order.calculateTotal(this.items);
  }

  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new InvalidOrderStateError('Cannot ship unpaid order');
    }
    
    this.status = OrderStatus.SHIPPED;
  }

  private static calculateTotal(items: OrderItem[]): Money {
    return items.reduce(
      (sum, item) => sum.add(item.getTotal()),
      Money.zero('USD'),
    );
  }
}

Value Objects

Immutable objects that represent domain concepts:

typescript
// domain/value-objects/order-id.ts
export class OrderId {
  constructor(private readonly value: string) {
    if (!value || value.length === 0) {
      throw new InvalidOrderIdError();
    }
  }

  static generate(): OrderId {
    return new OrderId(uuid());
  }

  toString(): string {
    return this.value;
  }

  equals(other: OrderId): boolean {
    return this.value === other.value;
  }
}

// domain/value-objects/money.ts
export class Money {
  constructor(
    private readonly amount: number,
    private readonly currency: string,
  ) {
    if (amount < 0) {
      throw new NegativeMoneyError();
    }
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError();
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  static zero(currency: string): Money {
    return new Money(0, currency);
  }
}

Domain Services

Business logic that doesn't belong to a single entity:

typescript
// domain/services/pricing.service.ts
export class PricingDomainService {
  calculateDiscount(
    order: Order,
    customer: Customer,
    promotions: Promotion[],
  ): Money {
    let discount = Money.zero(order.getCurrency());

    // Apply customer tier discount
    if (customer.isVip()) {
      discount = discount.add(order.getTotal().multiply(0.1));
    }

    // Apply promotional discounts
    for (const promotion of promotions) {
      if (promotion.appliesTo(order)) {
        discount = discount.add(promotion.calculateDiscount(order));
      }
    }

    return discount;
  }
}

Domain Errors

Business rule violations as explicit domain concepts:

typescript
// domain/errors/order.errors.ts
export abstract class OrderDomainError extends Error {
  constructor(message: string) {
    super(message);
    this.name = this.constructor.name;
  }
}

export class EmptyOrderError extends OrderDomainError {
  constructor() {
    super('Order must contain at least one item');
  }
}

export class OrderNotModifiableError extends OrderDomainError {
  constructor() {
    super('Order cannot be modified in its current state');
  }
}

export class InsufficientInventoryError extends OrderDomainError {
  constructor(productId: string, requested: number, available: number) {
    super(`Insufficient inventory for product ${productId}: requested ${requested}, available ${available}`);
  }
}

Use Cases Layer

Application-specific business rules that orchestrate domain objects.

Use Case Structure

typescript
// use-cases/create-order.use-case.ts
@Injectable()
export class CreateOrderUseCase {
  constructor(
    @Inject('ORDER_REPOSITORY')
    private orderRepository: OrderRepositoryInterface,
    @Inject('INVENTORY_SERVICE')
    private inventoryService: InventoryServiceInterface,
    @Inject('PAYMENT_SERVICE')
    private paymentService: PaymentServiceInterface,
    private pricingService: PricingDomainService,
  ) {}

  async execute(request: CreateOrderRequest): Promise<CreateOrderResponse> {
    // 1. Validate inventory availability
    const availability = await this.inventoryService.checkAvailability(
      request.items,
    );
    
    if (!availability.isAvailable) {
      throw new InsufficientInventoryError(
        availability.unavailableItems[0].productId,
        availability.unavailableItems[0].requested,
        availability.unavailableItems[0].available,
      );
    }

    // 2. Create domain entity
    const order = Order.create(
      new CustomerId(request.customerId),
      request.items.map(item => OrderItem.create(
        item.productId,
        item.quantity,
        item.price,
      )),
    );

    // 3. Apply pricing rules
    const customer = await this.customerRepository.findById(request.customerId);
    const promotions = await this.promotionRepository.findActive();
    const discount = this.pricingService.calculateDiscount(
      order,
      customer,
      promotions,
    );
    order.applyDiscount(discount);

    // 4. Reserve inventory
    await this.inventoryService.reserve(order.getId(), request.items);

    // 5. Process payment
    const paymentResult = await this.paymentService.charge(
      customer.getPaymentMethod(),
      order.getTotal(),
    );

    if (!paymentResult.success) {
      await this.inventoryService.release(order.getId());
      throw new PaymentFailedError(paymentResult.reason);
    }

    order.markAsPaid(paymentResult.transactionId);

    // 6. Persist order
    await this.orderRepository.save(order);

    // 7. Return response
    return {
      orderId: order.getId().toString(),
      total: order.getTotal().toObject(),
      status: order.getStatus(),
    };
  }
}

Use Case Interfaces

Define what the use case needs from the outside world:

typescript
// use-cases/interfaces/order-repository.interface.ts
export interface OrderRepositoryInterface {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByCustomer(customerId: CustomerId): Promise<Order[]>;
}

// use-cases/interfaces/inventory-service.interface.ts
export interface InventoryServiceInterface {
  checkAvailability(items: OrderItem[]): Promise<AvailabilityResult>;
  reserve(orderId: OrderId, items: OrderItem[]): Promise<void>;
  release(orderId: OrderId): Promise<void>;
}

// use-cases/interfaces/payment-service.interface.ts
export interface PaymentServiceInterface {
  charge(method: PaymentMethod, amount: Money): Promise<PaymentResult>;
  refund(transactionId: string, amount: Money): Promise<RefundResult>;
}

Infrastructure Layer

NestJS-specific implementations and external integrations.

Controllers (Inbound Adapters)

Transform HTTP requests into use case calls:

typescript
// infrastructure/controllers/orders.controller.ts
@Controller('orders')
@ApiTags('orders')
export class OrdersController {
  constructor(
    private readonly createOrderUseCase: CreateOrderUseCase,
    private readonly getOrderUseCase: GetOrderUseCase,
    private readonly shipOrderUseCase: ShipOrderUseCase,
  ) {}

  @Post()
  @ApiOperation({ summary: 'Create a new order' })
  @ApiResponse({ status: 201, type: OrderDto })
  @UseGuards(AuthGuard)
  async createOrder(
    @Body() dto: CreateOrderDto,
    @CurrentUser() user: UserContext,
  ): Promise<OrderDto> {
    try {
      const result = await this.createOrderUseCase.execute({
        customerId: user.id,
        items: dto.items.map(item => ({
          productId: item.productId,
          quantity: item.quantity,
          price: Money.fromDto(item.price),
        })),
      });

      return OrderDto.fromDomain(result);
    } catch (error) {
      if (error instanceof InsufficientInventoryError) {
        throw new BadRequestException(error.message);
      }
      if (error instanceof PaymentFailedError) {
        throw new PaymentRequiredException(error.message);
      }
      throw error;
    }
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get order by ID' })
  @ApiResponse({ status: 200, type: OrderDto })
  @ApiResponse({ status: 404, description: 'Order not found' })
  async getOrder(@Param('id') id: string): Promise<OrderDto> {
    const order = await this.getOrderUseCase.execute({ orderId: id });
    
    if (!order) {
      throw new NotFoundException(`Order ${id} not found`);
    }

    return OrderDto.fromDomain(order);
  }
}

DTOs (Data Transfer Objects)

Define API contracts separate from domain models:

typescript
// infrastructure/dto/create-order.dto.ts
export class CreateOrderDto {
  @ApiProperty({ type: [OrderItemDto] })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)
  items: OrderItemDto[];
}

export class OrderItemDto {
  @ApiProperty()
  @IsUUID()
  productId: string;

  @ApiProperty({ minimum: 1 })
  @IsInt()
  @Min(1)
  quantity: number;

  @ApiProperty()
  @ValidateNested()
  @Type(() => MoneyDto)
  price: MoneyDto;
}

export class OrderDto {
  @ApiProperty()
  id: string;

  @ApiProperty()
  customerId: string;

  @ApiProperty({ type: [OrderItemDto] })
  items: OrderItemDto[];

  @ApiProperty()
  total: MoneyDto;

  @ApiProperty({ enum: OrderStatus })
  status: OrderStatus;

  static fromDomain(order: Order): OrderDto {
    return {
      id: order.getId().toString(),
      customerId: order.getCustomerId().toString(),
      items: order.getItems().map(item => ({
        productId: item.getProductId(),
        quantity: item.getQuantity(),
        price: MoneyDto.fromDomain(item.getPrice()),
      })),
      total: MoneyDto.fromDomain(order.getTotal()),
      status: order.getStatus(),
    };
  }
}

Repository Implementations (Outbound Adapters)

Concrete implementations of repository interfaces:

typescript
// infrastructure/repositories/prisma-order.repository.ts
@Injectable()
export class PrismaOrderRepository implements OrderRepositoryInterface {
  constructor(private readonly prisma: PrismaService) {}

  async save(order: Order): Promise<void> {
    const data = this.toPersistence(order);
    
    await this.prisma.order.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const record = await this.prisma.order.findUnique({
      where: { id: id.toString() },
      include: { items: true },
    });

    if (!record) {
      return null;
    }

    return this.toDomain(record);
  }

  private toPersistence(order: Order): any {
    return {
      id: order.getId().toString(),
      customer_id: order.getCustomerId().toString(),
      status: order.getStatus(),
      total_amount: order.getTotal().getAmount(),
      total_currency: order.getTotal().getCurrency(),
      items: {
        create: order.getItems().map(item => ({
          product_id: item.getProductId(),
          quantity: item.getQuantity(),
          price_amount: item.getPrice().getAmount(),
          price_currency: item.getPrice().getCurrency(),
        })),
      },
    };
  }

  private toDomain(record: any): Order {
    return Order.reconstitute(
      new OrderId(record.id),
      new CustomerId(record.customer_id),
      record.items.map((item: any) => OrderItem.reconstitute(
        item.product_id,
        item.quantity,
        new Money(item.price_amount, item.price_currency),
      )),
      record.status,
      new Money(record.total_amount, record.total_currency),
    );
  }
}

Module Configuration

Wire everything together in the NestJS module:

typescript
// infrastructure/orders.module.ts
@Module({
  imports: [PrismaModule, SharedModule],
  controllers: [OrdersController],
  providers: [
    // Domain Services
    PricingDomainService,
    
    // Use Cases
    CreateOrderUseCase,
    GetOrderUseCase,
    ShipOrderUseCase,
    CancelOrderUseCase,
    
    // Repository Implementations
    {
      provide: 'ORDER_REPOSITORY',
      useClass: PrismaOrderRepository,
    },
    
    // External Service Implementations
    {
      provide: 'INVENTORY_SERVICE',
      useClass: HttpInventoryService,
    },
    {
      provide: 'PAYMENT_SERVICE',
      useClass: StripePaymentService,
    },
  ],
  exports: [
    // Export use cases if other modules need them
    GetOrderUseCase,
  ],
})
export class OrdersModule {}

Error Handling Across Layers

Domain → Use Case → Controller

typescript
// Domain throws business error
export class Order {
  ship(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new InvalidOrderStateError('Cannot ship unpaid order');
    }
  }
}

// Use case propagates or handles
export class ShipOrderUseCase {
  async execute(request: ShipOrderRequest): Promise<void> {
    const order = await this.orderRepository.findById(request.orderId);
    
    try {
      order.ship(); // Might throw InvalidOrderStateError
      await this.shippingService.createShipment(order);
      await this.orderRepository.save(order);
    } catch (error) {
      if (error instanceof InvalidOrderStateError) {
        // Could handle or propagate
        throw error;
      }
      throw new UnexpectedError();
    }
  }
}

// Controller translates to HTTP
@Controller('orders')
export class OrdersController {
  @Post(':id/ship')
  async shipOrder(@Param('id') id: string): Promise<void> {
    try {
      await this.shipOrderUseCase.execute({ orderId: id });
    } catch (error) {
      if (error instanceof InvalidOrderStateError) {
        throw new BadRequestException(error.message);
      }
      if (error instanceof OrderNotFoundError) {
        throw new NotFoundException(error.message);
      }
      throw new InternalServerErrorException();
    }
  }
}

Best Practices

DO ✅

  • Keep domain layer pure - No framework dependencies
  • Define repository interfaces in use cases - Not in infrastructure
  • Use DTOs for API boundaries - Don't expose domain models
  • Throw domain-specific errors - Not HTTP exceptions
  • Test each layer appropriately - Unit for domain/use cases, integration for infrastructure

DON'T ❌

  • Import NestJS in domain - Keep it framework-agnostic
  • Leak DTOs into use cases - Transform at the boundary
  • Put business logic in controllers - They should only adapt
  • Use database entities as domain models - Keep them separate
  • Bypass layers - Controllers shouldn't access repositories directly

Next Steps