rder | OrderDomainError {
if (!payload.customerId) {
return new OrderDomainError('INVALID_CUSTOMER', 'Customer ID is required');
}
if (payload.items.length === 0) {
return new OrderDomainError('EMPTY_CART', 'Order must contain at least one item');
}
const total = payload.items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
if (total <= 0) {
return new OrderDomainError('INVALID_TOTAL', 'Order total must be positive');
}
return {
id: crypto.randomUUID(),
customerId: payload.customerId,
items: payload.items,
status: 'PENDING',
totalAmount: total,
createdAt: new Date(),
};
}
**Rationale:** Domain objects are plain data structures. Validation happens at creation time, not scattered across controllers or repositories. This guarantees that any `Order` instance in the system satisfies business invariants.
### Step 2: Implement Use Cases
Use cases orchestrate business scenarios. They depend on domain types and infrastructure ports, but never on controllers, DTOs, or ORM entities. Each use case is a single responsibility handler.
```typescript
// src/modules/orders/use-case/place-order/place-order.handler.ts
import { Injectable } from '@nestjs/common';
import { Order, validateOrderCreation, OrderDomainError } from '../../domain/order';
import { OrderRepositoryPort } from '../../infrastructure/ports/order-repository.port';
import { InventoryCheckPort } from '../../inventory/external/inventory-check.port';
export interface PlaceOrderInput {
customerId: string;
items: Array<{ sku: string; quantity: number; unitPrice: number }>;
}
export interface PlaceOrderOutput {
orderId: string;
status: 'SUCCESS' | 'INSUFFICIENT_STOCK' | 'VALIDATION_FAILED';
message: string;
}
@Injectable()
export class PlaceOrderHandler {
constructor(
private readonly orderRepo: OrderRepositoryPort,
private readonly inventoryAdapter: InventoryCheckPort,
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderOutput> {
const domainResult = validateOrderCreation(input);
if (domainResult instanceof OrderDomainError) {
return { orderId: '', status: 'VALIDATION_FAILED', message: domainResult.message };
}
const stockCheck = await this.inventoryAdapter.reserveStock(input.items);
if (!stockCheck.success) {
return { orderId: '', status: 'INSUFFICIENT_STOCK', message: stockCheck.reason };
}
const savedOrder = await this.orderRepo.save(domainResult);
return { orderId: savedOrder.id, status: 'SUCCESS', message: 'Order placed' };
}
}
Rationale: Use cases are framework-agnostic. They receive plain input, execute business rules, and return plain output. Dependencies are injected as interfaces (ports), enabling unit testing without database or HTTP mocks.
Step 3: Build Infrastructure
Infrastructure handles persistence, external APIs, and framework-specific concerns. It implements ports defined by use cases and maps between ORM entities and domain objects.
// src/modules/orders/infrastructure/repositories/order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';
@Entity('orders')
export class OrderEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
customerId: string;
@Column('json')
items: Array<{ sku: string; quantity: number; unitPrice: number }>;
@Column()
status: string;
@Column('decimal')
totalAmount: number;
@CreateDateColumn()
createdAt: Date;
}
// src/modules/orders/infrastructure/repositories/order.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderEntity } from './order.entity';
import { Order } from '../../domain/order';
import { OrderRepositoryPort } from '../ports/order-repository.port';
@Injectable()
export class OrderRepository implements OrderRepositoryPort {
constructor(
@InjectRepository(OrderEntity)
private readonly repo: Repository<OrderEntity>,
) {}
async save(order: Order): Promise<Order> {
const entity = this.mapToEntity(order);
const saved = await this.repo.save(entity);
return this.mapToDomain(saved);
}
private mapToEntity(domain: Order): OrderEntity {
return {
id: domain.id,
customerId: domain.customerId,
items: domain.items,
status: domain.status,
totalAmount: domain.totalAmount,
createdAt: domain.createdAt,
};
}
private mapToDomain(entity: OrderEntity): Order {
return {
id: entity.id,
customerId: entity.customerId,
items: entity.items,
status: entity.status as Order['status'],
totalAmount: Number(entity.totalAmount),
createdAt: entity.createdAt,
};
}
}
Rationale: The repository owns the ORM entity. It never leaks OrderEntity outside the infrastructure layer. Mapping happens explicitly, ensuring domain objects remain pure. Swapping TypeORM for Prisma or raw SQL only requires rewriting this layer.
Step 4: Create Presentation Layer
The presentation layer handles transport concerns: HTTP controllers, request validation, DTOs, and response formatting. It knows nothing about business rules or database schemas.
// src/modules/orders/presentation/controllers/order.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { PlaceOrderHandler, PlaceOrderInput, PlaceOrderOutput } from '../../use-case/place-order/place-order.handler';
import { CreateOrderDto } from '../dto/create-order.dto';
@Controller('orders')
export class OrderController {
constructor(private readonly placeOrder: PlaceOrderHandler) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateOrderDto): Promise<PlaceOrderOutput> {
const input: PlaceOrderInput = {
customerId: dto.customerId,
items: dto.items.map(i => ({ sku: i.sku, quantity: i.quantity, unitPrice: i.unitPrice })),
};
return this.placeOrder.execute(input);
}
}
Rationale: Controllers are thin adapters. They transform HTTP payloads into use case inputs and return use case outputs directly. Validation happens via DTOs, but business validation remains in the domain/use case layer.
Step 5: Establish Cross-Module Contracts (Ports)
Modules communicate through explicit interfaces, not direct service imports. This mimics the microservice "database per service" pattern within a monolith.
// src/modules/inventory/external/inventory-check.port.ts
export interface InventoryCheckPort {
reserveStock(items: Array<{ sku: string; quantity: number }>): Promise<{
success: boolean;
reason?: string;
}>;
}
// src/modules/inventory/external/inventory-external.service.ts
import { Injectable } from '@nestjs/common';
import { InventoryCheckPort } from './inventory-check.port';
import { InventoryRepository } from '../infrastructure/repositories/inventory.repository';
@Injectable()
export class InventoryExternalService implements InventoryCheckPort {
constructor(private readonly repo: InventoryRepository) {}
async reserveStock(items: Array<{ sku: string; quantity: number }>): Promise<{ success: boolean; reason?: string }> {
const available = await this.repo.checkAvailability(items);
if (!available.allAvailable) {
return { success: false, reason: 'One or more items are out of stock' };
}
await this.repo.decrementStock(items);
return { success: true };
}
}
Rationale: Ports define exactly what a module exposes. They prevent implicit coupling, make cross-module dependencies explicit in the DI graph, and enable independent module testing. The Orders module never imports InventoryService directly; it depends on InventoryCheckPort.
Pitfall Guide
1. Leaking ORM Entities to Use Cases
Explanation: Developers pass TypeORM/Prisma entities directly into use cases or return them from repositories. This binds business logic to database schema, decorators, and lazy-loading behavior.
Fix: Always map entities to plain domain objects at the infrastructure boundary. Use cases should only receive and return TypeScript interfaces/types.
2. Over-Engineering Port Interfaces
Explanation: Creating granular ports for every single repository method leads to interface bloat and maintenance overhead. Not every internal method needs to be a contract.
Fix: Design ports around business capabilities, not data access patterns. A port should expose what neighboring modules actually need, not every CRUD operation.
3. Mixing Validation Logic into Controllers
Explanation: Controllers perform business validation (e.g., checking if a user has permission, validating order totals) instead of delegating to use cases. This creates duplicate validation logic across HTTP, CLI, and queue handlers.
Fix: Controllers only validate transport concerns (required fields, type coercion, format). Business rules live in domain/use case layers.
4. Ignoring Error Boundary Contracts
Explanation: Use cases throw framework-specific errors or return mixed success/failure types without a consistent contract. This forces presentation layers to handle unpredictable error shapes.
Fix: Define explicit result types or error codes at the use case boundary. Use a consistent pattern like Result<T, E> or structured response objects with status codes.
5. Circular Dependencies via Shared Infrastructure
Explanation: Two modules import the same database connection or shared utility service, creating implicit coupling that bypasses port contracts.
Fix: Each module owns its infrastructure dependencies. If sharing is necessary, extract the shared concern into a dedicated shared/ module with explicit exports, and import it deliberately.
6. Treating Use Cases as Service Facades
Explanation: Use cases become thin wrappers that just call this.userService.create() and this.emailService.send(). This defeats the purpose of the layer and recreates bloated services.
Fix: Use cases should contain orchestration logic, business rule evaluation, and cross-domain coordination. If a use case has no logic, question whether it needs to exist.
7. Skipping Domain Invariant Enforcement
Explanation: Domain objects are created as plain objects without validation, allowing invalid states to propagate through the system.
Fix: Use factory functions or constructors that validate invariants at creation time. Never allow partially constructed domain objects to exist in the codebase.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small MVP (< 5 modules) | Simplified layering (Domain + Use Case + Infrastructure) | Full port contracts add overhead for small teams | Lower initial setup time |
| Medium product (5-15 modules) | Feature-Based Clean Architecture with explicit ports | Prevents dependency cycles as team scales | Moderate upfront investment, high long-term ROI |
| Large enterprise (> 15 modules) | Strict port contracts + architectural linting + CI boundary checks | Enforces constraints automatically across distributed teams | High initial cost, prevents technical debt accumulation |
| Legacy migration | Gradual strangler pattern: wrap existing services with ports | Avoids big-bang rewrite while enforcing new boundaries | Phased cost, minimal downtime risk |
Configuration Template
// src/modules/orders/orders.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrderEntity } from './infrastructure/repositories/order.entity';
import { OrderRepository } from './infrastructure/repositories/order.repository';
import { PlaceOrderHandler } from './use-case/place-order/place-order.handler';
import { OrderController } from './presentation/controllers/order.controller';
import { InventoryExternalModule } from '../inventory/external/inventory-external.module';
@Module({
imports: [
TypeOrmModule.forFeature([OrderEntity]),
InventoryExternalModule, // Explicit cross-module contract
],
controllers: [OrderController],
providers: [
OrderRepository,
PlaceOrderHandler,
],
exports: [PlaceOrderHandler], // Only export what other modules need
})
export class OrdersModule {}
// src/modules/inventory/external/inventory-external.module.ts
import { Module } from '@nestjs/common';
import { InventoryExternalService } from './inventory-external.service';
import { InventoryRepository } from '../infrastructure/repositories/inventory.repository';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InventoryEntity } from '../infrastructure/repositories/inventory.entity';
@Module({
imports: [TypeOrmModule.forFeature([InventoryEntity])],
providers: [InventoryRepository, InventoryExternalService],
exports: [InventoryExternalService], // Exposes only the port implementation
})
export class InventoryExternalModule {}
Quick Start Guide
- Initialize module structure: Create
domain/, use-case/, infrastructure/, presentation/, and external/ folders inside a new feature module.
- Define domain contracts: Write plain TypeScript interfaces and validation functions. No decorators, no framework imports.
- Build use case handlers: Create handler classes that accept ports and domain types. Implement business logic without HTTP or database concerns.
- Wire infrastructure: Implement repositories with ORM entities. Map entities to domain objects explicitly. Register providers in the module.
- Connect presentation: Create controllers that transform DTOs to use case inputs. Inject handlers and return outputs directly. Run tests to verify boundary isolation.