Domain-Driven Design (DDD) with TypeScript
DDD focuses on the core domain and domain logic, basing complex design on a model of the domain.
Building Blocks
Value Objects
Immutable objects defined by their attributes, no identity.
class Money {
private constructor(
readonly amount: number,
readonly currency: string
) {}
static of(amount: number, currency: string): Money {
if (amount < 0) throw new Error('Amount cannot be negative');
if (!currency || currency.length !== 3) throw new Error('Invalid currency code');
return new Money(amount, currency);
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error('Cannot add different currencies');
}
return Money.of(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
toString(): string {
return `${this.amount} ${this.currency}`;
}
}
Entities
Objects with a unique identity that persists over time.
abstract class Entity<T> {
constructor(protected readonly id: T) {}
equals(other: Entity<T>): boolean {
return this.id === other.id;
}
}
class Order extends Entity<string> {
private _status: OrderStatus = 'pending';
private _items: OrderItem[] = [];
constructor(
id: string,
private readonly customerId: string,
private readonly createdAt: Date = new Date()
) {
super(id);
}
addItem(product: Product, quantity: number): void {
if (this._status !== 'pending') {
throw new Error('Cannot modify a non-pending order');
}
const existing = this._items.find(i => i.productId === product.id);
if (existing) {
existing.increaseQuantity(quantity);
} else {
this._items.push(new OrderItem(product.id, product.price, quantity));
}
}
confirm(): void {
if (this._items.length === 0) throw new Error('Order has no items');
if (this._status !== 'pending') throw new Error('Order already confirmed');
this._status = 'confirmed';
}
get total(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.subtotal),
Money.of(0, 'USD')
);
}
}
Aggregates
Clusters of entities/value objects with one root that controls access.
// Order is the aggregate root
class OrderAggregate {
private events: DomainEvent[] = [];
static create(customerId: string): OrderAggregate {
const order = new OrderAggregate(
generateId(),
customerId,
new Date()
);
order.addEvent(new OrderCreatedEvent(order.id, customerId));
return order;
}
ship(trackingCode: string): void {
if (this._status !== 'confirmed') {
throw new Error('Order must be confirmed before shipping');
}
this._status = 'shipped';
this._trackingCode = trackingCode;
this.addEvent(new OrderShippedEvent(this.id, trackingCode));
}
private addEvent(event: DomainEvent): void {
this.events.push(event);
}
getUncommittedEvents(): DomainEvent[] {
return [...this.events];
}
clearEvents(): void {
this.events = [];
}
}
Domain Services
Operations that don't naturally fit within an entity or value object.
class OrderPricingService {
constructor(
private discountRepository: DiscountRepository,
private taxService: TaxService
) {}
async calculateTotal(order: Order, customerId: string): Promise<Money> {
let total = order.subtotal;
// Apply customer discounts
const discounts = await this.discountRepository.findForCustomer(customerId);
for (const discount of discounts) {
total = discount.apply(total);
}
// Add taxes
const tax = await this.taxService.calculate(total, order.shippingAddress);
return total.add(tax);
}
}
Bounded Contexts
┌─────────────────────┐ ┌─────────────────────┐
│ Order Context │ │ Inventory Context │
│ ───────────────── │ │ ─────────────────── │
│ Order │ │ Product │
│ OrderItem │◄──►│ Stock │
│ Customer (ref) │ │ Warehouse │
└─────────────────────┘ └─────────────────────┘
│
▼
┌─────────────────────┐
│ Payment Context │
│ ───────────────── │
│ Payment │
│ Invoice │
│ Customer (ref) │
└─────────────────────┘
DDD pays off most in complex business domains where the logic is the hard part.