Back to Articles
📄
Domain-Driven Designbounded contextmicroservicesaggregatesdomain eventsvalue objectsentity

What is Domain-Driven Design and Which Aspects Are Useful When

Domain‑Driven Design aligns code with business language, using bounded contexts to isolate differing meanings of the same term. Tactical patterns such as entities, value objects, aggregates and domain events keep models consistent while reducing coupling.

What is Domain-Driven Design and Which Aspects Are Useful When

Domain-Driven Design (DDD) addresses semantic divergence between software implementations and real-world business models. When technical abstractions diverge from domain terminology, architectural misinterpretations cascade into systemic defects. DDD establishes a shared ubiquitous language and partitions large systems into explicit Bounded Contexts, preventing conflicting domain definitions from corrupting shared schemas.

Strategic DDD: Bounded Contexts

The most important concept in DDD is the bounded context. A bounded context is an explicit boundary inside which a particular domain model is valid. Outside that boundary, the same terms are allowed to mean something different. Consider an e-commerce system:

  • In the Catalog Context, a "Product" has a name, description, and price.
  • In the Warehouse Context, the same "Product" has dimensions, weight, and stock levels.
  • In the Customer Support Context, "Product" might be the item a customer is complaining about.

Each context owns its model. Teams often avoid sharing database tables or domain classes across contexts; instead, the contexts communicate through explicit contracts. This prevents accidental coupling and keeps each model pure.

When to Use Bounded Contexts

Bounded contexts shine when:

  • Different teams own different parts of the domain.
  • The same term has different meanings in different parts of the business.
  • Different parts change for different reasons.
  • You're moving toward microservices and need natural boundaries.

If your monolith has modules that rarely change together, those modules are strong candidates for bounded contexts. Separating them reduces the ripple effect of changes, because a modification in one module is unlikely to force a change in another.

Tactical DDD: The Building Blocks

Once you've defined your bounded contexts, tactical patterns help you model the inside of each context precisely.

Entities

An entity is an object with a distinct identity that persists over time. Two entities with identical attributes are not the same if they have different IDs.

class Order {
  constructor(
    private readonly id: OrderId,
    private customerId: CustomerId,
    private status: OrderStatus,
    private items: OrderItem[]
  ) {}

  getId(): OrderId {
    return this.id;
  }
}

A Customer with ID abc123 remains the same entity even if their name changes. An Address without an ID is typically a value object.

Value Objects

A value object has no identity. It is defined entirely by its attributes. Two addresses with the same street, city, and postal code are interchangeable.

class Address {
  constructor(
    private readonly street: string,
    private readonly city: string,
    private readonly postalCode: string
  ) {}

  equals(other: Address): boolean {
    return (
      this.street === other.street &&
      this.city === other.city &&
      this.postalCode === other.postalCode
    );
  }
}

Value objects are immutable. Replace them rather than modify them, because immutability guarantees that a value cannot change behind a caller’s back.

Aggregates

An aggregate defines a consistency boundary around one or more entities. Exactly one entity in an aggregate is the aggregate root. External code accesses the aggregate only through the root.

class OrderAggregate {
  private constructor(
    private root: Order,
    private items: OrderItem[]
  ) {}

  static create(id: OrderId, customerId: CustomerId): OrderAggregate {
    const order = new Order(id, customerId, OrderStatus.Draft, []);
    return new OrderAggregate(order, []);
  }

  addItem(product: Product, quantity: number): void {
    if (this.root.status !== OrderStatus.Draft) {
      throw new OrderAlreadyPlacedException();
    }
    this.items.push(new OrderItem(product.getId(), quantity, product.getPrice()));
  }

  place(): void {
    if (this.items.length === 0) {
      throw new EmptyOrderException();
    }
    this.root.status = OrderStatus.Placed;
  }
}

The aggregate enforces all invariants. If addItem is called on a placed order, it throws. No other code can bypass this rule because the root shields internal state and guarantees that only valid transitions occur.

Aggregates in Practice: Small Is Beautiful

In high-throughput systems, large aggregates become locking hotspots. The practical guidance is to design small aggregates, include only data that must stay consistent within one transaction, and reference other aggregates by ID rather than storing direct references. Use eventual consistency across aggregates when appropriate; domain events are a common option for processes that span multiple aggregates.

// Delivery references Drone and Package by ID, not by object
class Delivery {
  constructor(
    private readonly id: DeliveryId,
    private readonly droneId: DroneId,
    private readonly packageId: PackageId,
    private status: DeliveryStatus
  ) {}
}

Domain Events

Aggregates raise domain events when they change state. Other parts of the system subscribe to these events to react.

class Delivery extends AggregateRoot {
  complete(): void {
    this.status = DeliveryStatus.Completed;
    this.raise(new DeliveryCompletedEvent(this.id, this.completedAt));
  }
}

This decouples aggregates. The Delivery aggregate does not need to know who cares that a delivery completed; it merely announces the fact, and any interested listener can act on it.

Strategic Patterns: Context Maps

When multiple bounded contexts need to interact, the context map defines the relationship between them. Common integration patterns include:

  • Customer/Supplier: One context provides, the other consumes. They negotiate APIs together.
  • Conformist: One side adopts the other's model without negotiation.
  • Anticorruption Layer: A context wraps an external system and translates its model.
  • Open Host Service: A context publishes a protocol for others to use.
  • Shared Kernel: Two contexts share a small, common model.
CatalogContext [D]<-[U,OHS,PL] WarehouseContext
CustomerContext [SK]<->[SK] OrderContext
SupportContext -> [ACL] -> ExternalLegacySystem

When DDD Is Overkill

DDD introduces structural modeling overhead requiring close collaboration between domain experts and software architects. For simple CRUD services with minimal business logic, formal tactical DDD adds unnecessary ceremony. Conversely, DDD provides essential architectural structure for distributed systems with complex business rules, long lifecycle horizons, and multi-team boundaries where domain isolation preserves architectural integrity.

Example: E-commerce with DDD

Putting it all together for an order system:

// Each is an aggregate root with its own lifecycle
class Customer {
  addOrder(order: OrderId): void { /* ... */ }
}

class Order {
  addItem(product: ProductId, qty: number): void { /* ... */ }
  place(): void { /* ... */ }
}

class Product {
  adjustPrice(newPrice: Money): void { /* ... */ }
}

Each aggregate maintains its own invariants. Customer and Order are separate aggregates because they have different lifecycles and consistency requirements; a change to a customer's contact details should never force a revalidation of an order's line items.

Sources & References

  1. [1]
  2. [2]
  3. [3]
  4. [4]

Comments (0)

Loading comments...