Domain-Driven Design
Domain-Driven Design (DDD) is a software engineering approach that centers the design of the software around the business domain, its rules, and its terminology, rather than focusing purely on technical infrastructure (like databases or APIs).
Overview
DDD is not a framework or a specific folder structure; it’s a way of thinking. In complex domains (like Banking, Logistics, or Healthcare), the business rules are the most critical and complex part of the application.
In a standard CRUD app, an “Order” is just a row in a database table. In a DDD app, an “Order” is a rich Domain Object that enforces business rules (e.g., “An order cannot be shipped if it hasn’t been paid”). NestJS, with its strong typing and modularity, provides an excellent environment for building DDD applications.
Key Concepts
- Ubiquitous Language: The code should use the exact same terminology as the business experts. If the sales team calls it a “Client”, your class should be
Client, notCustomerAccount. - Entities: Objects that have a distinct identity that runs through time and different states (e.g., a
Userwith an ID). - Value Objects: Objects that have no conceptual identity and describe some characteristic of a thing (e.g., an
AddressorMoney). Two $5 bills are identical and interchangeable. - Aggregates: A cluster of domain objects that can be treated as a single unit. For example, an
Order(Aggregate Root) containsOrderLineItems. You should only interact with the Line Items through the Order. - Repositories: Interfaces that abstract away the persistence of Aggregates.
Code Examples
1. Value Object
A Value Object is immutable. If you want to change it, you replace it entirely. It encapsulates validation rules.
// domain/value-objects/money.vo.ts
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string,
) {
if (amount < 0) throw new Error('Money cannot be negative');
if (!['USD', 'EUR', 'GBP'].includes(currency)) {
throw new Error('Unsupported currency');
}
}
// Value objects handle their own logic
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
// Returns a NEW immutable object
return new Money(this.amount + other.amount, this.currency);
}
}
2. Entity & Aggregate Root
An Entity has an ID and contains business logic. It is not an anemic data container (just getters/setters).
// domain/entities/account.entity.ts
import { Money } from '../value-objects/money.vo';
export class Account {
constructor(
public readonly id: string, // Identity
private balance: Money, // Value Object
private status: 'ACTIVE' | 'FROZEN',
) {}
// Business Logic Encapsulated in the Aggregate Root!
withdraw(amount: Money): void {
if (this.status === 'FROZEN') {
throw new Error('Cannot withdraw from a frozen account');
}
if (amount.currency !== this.balance.currency) {
throw new Error('Currency mismatch');
}
if (amount.amount > this.balance.amount) {
throw new Error('Insufficient funds');
}
this.balance = new Money(this.balance.amount - amount.amount, this.balance.currency);
}
getBalance(): Money {
return this.balance;
}
}
3. Application Service (Use Case)
The Application layer coordinates the infrastructure (Repositories) and the Domain (Aggregates). It does NOT contain business logic itself!
// application/services/withdrawal.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { IAccountRepository } from '../../domain/repositories/account.repository.interface';
import { Money } from '../../domain/value-objects/money.vo';
@Injectable()
export class WithdrawalService {
constructor(
@Inject('IAccountRepository')
private readonly repository: IAccountRepository
) {}
async execute(accountId: string, amountToWithdraw: number): Promise<void> {
// 1. Fetch the aggregate from infrastructure
const account = await this.repository.findById(accountId);
if (!account) throw new Error('Account not found');
// 2. Execute business logic on the Aggregate
const money = new Money(amountToWithdraw, 'USD');
account.withdraw(money); // The Account ensures this is a valid operation!
// 3. Save the state back to infrastructure
await this.repository.save(account);
}
}
Best Practices
- Avoid Anemic Domain Models: A common anti-pattern is creating entities that are just bags of data (only getters and setters) and putting all the
if/elsebusiness logic into theWithdrawalService. In DDD, theWithdrawalServiceshould be incredibly “dumb”, and theAccountentity should be “smart”. - Isolate the Domain: Your Domain entities (
Account,Money) should have absolute ZERO imports from@nestjs/common,typeorm,mongoose, or any external libraries. They should be pure, easily testable TypeScript code. You map these pure Domain Entities to TypeORM Entities down in the Infrastructure layer.