TechLead
Lesson 3 of 27
6 min read
Software Architecture

Domain-Driven Design (DDD)

Master strategic and tactical DDD patterns including ubiquitous language, bounded contexts, aggregates, and value objects

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 invariantsThe aggregate root enforces all business rules within the boundary
Reference by IDAggregates reference other aggregates only by their IDs, not by direct object references
One transaction per aggregateOnly one aggregate should be modified per transaction
Small aggregatesKeep 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

Continue Learning