What is Domain-Driven Design?
Domain-Driven Design (DDD) is a software development approach introduced by Eric Evans that focuses on modeling software to match the business domain. DDD provides both strategic patterns for organizing large systems and tactical patterns for building rich domain models. The core premise is that the most significant complexity in most software is not technical — it is in the domain itself.
DDD Strategic vs Tactical
- Strategic DDD: Deals with large-scale structure — bounded contexts, context maps, subdomains, and team organization
- Tactical DDD: Deals with implementation patterns — entities, value objects, aggregates, repositories, domain events, and services
- Ubiquitous Language: A shared language between developers and domain experts used consistently in code, documentation, and conversation
Ubiquitous Language
The ubiquitous language is the foundation of DDD. It is a strictly defined language shared by developers and domain experts. Every term must have a precise meaning within the bounded context. When the language changes, the code changes — and vice versa.
Language Example: E-Commerce Domain
- Order: A confirmed request by a Customer to purchase one or more Products
- Line Item: A specific Product and quantity within an Order
- Shopping Cart: A temporary collection of intended purchases before checkout
- Fulfillment: The process of picking, packing, and shipping an Order
- Backorder: When an ordered Product is not in stock and will be shipped later
Entities
Entities are objects defined primarily by their identity rather than their attributes. Two entities with the same attributes but different IDs are different entities. They have a lifecycle and their state can change over time.
// domain/entities/Order.ts
export class Order {
private _lineItems: LineItem[] = [];
private _status: OrderStatus;
private _placedAt: Date | null = null;
constructor(
public readonly id: OrderId,
public readonly customerId: CustomerId,
status?: OrderStatus
) {
this._status = status || OrderStatus.DRAFT;
}
addLineItem(productId: ProductId, quantity: Quantity, unitPrice: Money): void {
if (this._status !== OrderStatus.DRAFT) {
throw new DomainError("Can only add items to draft orders");
}
const existing = this._lineItems.find(li => li.productId.equals(productId));
if (existing) {
existing.increaseQuantity(quantity);
} else {
this._lineItems.push(new LineItem(productId, quantity, unitPrice));
}
}
place(): OrderPlaced {
if (this._lineItems.length === 0) {
throw new DomainError("Cannot place an empty order");
}
if (this._status !== OrderStatus.DRAFT) {
throw new DomainError("Order is not in draft status");
}
this._status = OrderStatus.PLACED;
this._placedAt = new Date();
return new OrderPlaced(this.id, this.customerId, this.totalAmount, this._placedAt);
}
get totalAmount(): Money {
return this._lineItems.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero("USD")
);
}
get status(): OrderStatus { return this._status; }
get lineItems(): ReadonlyArray { return [...this._lineItems]; }
}
Value Objects
Value objects are defined by their attributes, not by an identity. Two value objects with the same attributes are considered equal. They are immutable — once created, they never change. Common examples include Money, Email, Address, and DateRange.
// domain/value-objects/Money.ts
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string
) {
if (amount < 0) throw new DomainError("Money amount cannot be negative");
}
static of(amount: number, currency: string): Money {
return new Money(Math.round(amount * 100) / 100, currency);
}
static zero(currency: string): Money {
return new Money(0, currency);
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return Money.of(this.amount + other.amount, this.currency);
}
subtract(other: Money): Money {
this.ensureSameCurrency(other);
return Money.of(this.amount - other.amount, this.currency);
}
multiply(factor: number): Money {
return Money.of(this.amount * factor, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new DomainError("Cannot operate on different currencies");
}
}
toString(): string {
return `${this.currency} ${this.amount.toFixed(2)}`;
}
}
// domain/value-objects/Quantity.ts
export class Quantity {
private constructor(public readonly value: number) {
if (!Number.isInteger(value) || value <= 0) {
throw new DomainError("Quantity must be a positive integer");
}
}
static of(value: number): Quantity {
return new Quantity(value);
}
add(other: Quantity): Quantity {
return Quantity.of(this.value + other.value);
}
equals(other: Quantity): boolean {
return this.value === other.value;
}
}
Aggregates
An aggregate is a cluster of domain objects that can be treated as a single unit. Each aggregate has a root entity (the aggregate root) and a boundary. External objects can only hold references to the aggregate root. All changes to the aggregate must go through the root, ensuring consistency invariants are maintained.
Aggregate Design Rules
| Rule | Description |
|---|---|
| Protect invariants | The aggregate root enforces all business rules within the boundary |
| Reference by ID | Aggregates reference other aggregates only by their IDs, not by direct object references |
| One transaction per aggregate | Only one aggregate should be modified per transaction |
| Small aggregates | Keep aggregates small — large aggregates lead to concurrency and performance issues |
Domain Events
Domain events capture something meaningful that happened in the domain. They are named in the past tense using ubiquitous language: OrderPlaced, PaymentReceived, ShipmentDispatched. Domain events enable loose coupling between aggregates and bounded contexts.
// domain/events/DomainEvent.ts
export abstract class DomainEvent {
public readonly occurredAt: Date;
constructor() {
this.occurredAt = new Date();
}
abstract get eventName(): string;
}
// domain/events/OrderPlaced.ts
export class OrderPlaced extends DomainEvent {
constructor(
public readonly orderId: OrderId,
public readonly customerId: CustomerId,
public readonly totalAmount: Money,
public readonly placedAt: Date
) {
super();
}
get eventName(): string {
return "OrderPlaced";
}
}
// domain/events/DomainEventPublisher.ts
type EventHandler = (event: T) => Promise;
export class DomainEventPublisher {
private handlers = new Map[]>();
subscribe(eventName: string, handler: EventHandler): void {
const existing = this.handlers.get(eventName) || [];
existing.push(handler as EventHandler);
this.handlers.set(eventName, existing);
}
async publish(event: DomainEvent): Promise {
const handlers = this.handlers.get(event.eventName) || [];
await Promise.all(handlers.map(handler => handler(event)));
}
}
Domain Services
When a significant process or transformation in the domain is not a natural responsibility of an entity or value object, use a domain service. Domain services are stateless operations that coordinate between multiple aggregates or encapsulate domain logic that does not belong to a single entity.
// domain/services/PricingService.ts
export class PricingService {
constructor(
private readonly discountPolicy: DiscountPolicy,
private readonly taxCalculator: TaxCalculator
) {}
calculateOrderTotal(
lineItems: ReadonlyArray,
customerTier: CustomerTier,
shippingAddress: Address
): OrderPricing {
const subtotal = lineItems.reduce(
(sum, item) => sum.add(item.subtotal),
Money.zero("USD")
);
const discount = this.discountPolicy.calculateDiscount(subtotal, customerTier);
const afterDiscount = subtotal.subtract(discount);
const tax = this.taxCalculator.calculate(afterDiscount, shippingAddress);
const total = afterDiscount.add(tax);
return { subtotal, discount, tax, total };
}
}
Repository Pattern in DDD
Repositories provide the illusion of an in-memory collection of all aggregate roots of a given type. They handle persistence concerns while the domain remains ignorant of storage mechanisms. Each aggregate root gets its own repository.
// domain/repositories/OrderRepository.ts (Port)
export interface OrderRepository {
save(order: Order): Promise;
findById(id: OrderId): Promise;
findByCustomer(customerId: CustomerId): Promise;
nextId(): OrderId;
}
// infrastructure/persistence/TypeOrmOrderRepository.ts (Adapter)
export class TypeOrmOrderRepository implements OrderRepository {
constructor(private readonly em: EntityManager) {}
async save(order: Order): Promise {
const record = this.toRecord(order);
await this.em.getRepository(OrderRecord).save(record);
}
async findById(id: OrderId): Promise {
const record = await this.em.getRepository(OrderRecord)
.findOne({ where: { id: id.value }, relations: ["lineItems"] });
return record ? this.toDomain(record) : null;
}
// Mapping between domain and persistence models
private toRecord(order: Order): OrderRecord { /* ... */ }
private toDomain(record: OrderRecord): Order { /* ... */ }
}
When to Use DDD
- Complex domain logic: When the business rules are intricate and evolving, DDD helps manage that complexity
- Domain expert availability: DDD requires close collaboration with domain experts — without them, it loses much of its value
- Long-lived projects: The investment in domain modeling pays off over time as the system evolves
- Not for CRUD apps: If the application is mostly data entry and retrieval with minimal business logic, DDD adds unnecessary complexity