Appearance
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 concernsThe 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
- Implement data access with Repository Pattern in NestJS
- Learn testing strategies in Unit Testing and Integration Testing
- Understand module organization in Modular Monolith in NestJS
Related Concepts
- Clean Architecture - Theoretical foundation
- Use Cases - Application business rules
- Dependency Inversion - Decoupling principles