Skip to content

Domain-Driven Design: Essential Building Blocks

Entities, value objects, aggregates, and bounded contexts — the DDD patterns that keep complex business logic organized without over-engineering simple systems.

3 min read
Domain model diagram showing bounded contexts and aggregate boundaries

Domain-Driven Design is a set of patterns for organizing complex business logic. It's not about using specific frameworks or folder structures — it's about modeling your software around the actual business domain, using a shared language between developers and domain experts. The tactical patterns (entities, value objects, aggregates) give you concrete tools for implementing that model.

Entities vs. Value Objects

An entity has a unique identity that persists over time. A value object is defined entirely by its attributes — two value objects with the same attributes are interchangeable.

tstypescript
// Entity: identified by its ID, attributes can change
class Order {
  constructor(
    readonly id: string,
    private items: OrderItem[],
    private status: OrderStatus
  ) {}
 
  addItem(item: OrderItem): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a non-draft order");
    }
    this.items.push(item);
  }
 
  // Two orders with the same items are still different orders
  equals(other: Order): boolean {
    return this.id === other.id;
  }
}
 
// Value Object: defined by its attributes, immutable
class Money {
  constructor(
    readonly amount: number,
    readonly currency: string
  ) {
    if (amount < 0) throw new Error("Amount cannot be negative");
    if (!currency.match(/^[A-Z]{3}$/)) throw new Error("Invalid currency code");
  }
 
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Cannot add different currencies");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
 
  // Two Money objects with the same amount and currency are equal
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates

An aggregate is a cluster of entities and value objects with a single root entity that controls all access. External code only references the aggregate through its root — this enforces consistency rules.

tstypescript
// ❌ Direct access to internals — no consistency guarantees
const order = await orderRepo.findById(orderId);
const item = order.items[0];
item.quantity = 100;  // Modified directly, bypassing business rules
await orderItemRepo.save(item);
 
// ✅ Access through aggregate root — invariants are enforced
class Order {
  private items: OrderItem[] = [];
  private status: OrderStatus = "draft";
 
  updateItemQuantity(itemId: string, newQuantity: number): void {
    if (this.status !== "draft") {
      throw new Error("Cannot modify a submitted order");
    }
 
    const item = this.items.find(i => i.id === itemId);
    if (!item) throw new Error(`Item ${itemId} not found`);
 
    if (newQuantity <= 0) {
      this.items = this.items.filter(i => i.id !== itemId);
      return;
    }
 
    if (newQuantity > 100) {
      throw new Error("Maximum quantity per item is 100");
    }
 
    item.updateQuantity(newQuantity);
  }
 
  get totalAmount(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.lineTotal),
      new Money(0, "USD")
    );
  }
}
 
// External code uses the aggregate root
const order = await orderRepo.findById(orderId);
order.updateItemQuantity(itemId, 5);
await orderRepo.save(order); // Saves the entire aggregate

Bounded Contexts

A bounded context defines the boundary within which a model applies. The same real-world concept (e.g., "User") can have different representations in different contexts.

tstypescript
// ❌ Shared "User" model across the entire system
interface User {
  id: string;
  name: string;
  email: string;
  shippingAddress: Address;     // Only relevant in orders
  creditScore: number;          // Only relevant in payments
  loginAttempts: number;        // Only relevant in auth
  preferredCategories: string[]; // Only relevant in recommendations
}
 
// ✅ Each bounded context has its own model
 
// Auth context — cares about credentials
interface AuthUser {
  id: string;
  email: string;
  passwordHash: string;
  loginAttempts: number;
  lockedUntil: Date | null;
}
 
// Order context — cares about shipping
interface OrderCustomer {
  id: string;
  name: string;
  shippingAddress: Address;
  loyaltyTier: "standard" | "premium";
}
 
// Payment context — cares about billing
interface PaymentCustomer {
  id: string;
  defaultPaymentMethod: string;
  billingAddress: Address;
  creditLimit: Money;
}

Domain Events

Domain events capture important things that happen within a bounded context. They're the primary mechanism for cross-context communication.

tstypescript
interface DomainEvent {
  type: string;
  occurredAt: string;
  aggregateId: string;
}
 
class Order {
  private domainEvents: DomainEvent[] = [];
 
  submit(): void {
    if (this.items.length === 0) {
      throw new Error("Cannot submit an empty order");
    }
 
    this.status = "submitted";
    this.submittedAt = new Date();
 
    // Record the domain event
    this.domainEvents.push({
      type: "order.submitted",
      occurredAt: new Date().toISOString(),
      aggregateId: this.id,
    });
  }
 
  pullDomainEvents(): DomainEvent[] {
    const events = [...this.domainEvents];
    this.domainEvents = [];
    return events;
  }
}
 
// Repository publishes events after saving
class OrderRepository {
  async save(order: Order): Promise<void> {
    await this.db.save(order);
 
    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }
  }
}

Repository Pattern

Repositories abstract the persistence mechanism. The domain model doesn't know about databases — it works with pure objects.

tstypescript
// ❌ Domain logic coupled to database queries
async function submitOrder(orderId: string) {
  const row = await db.query("SELECT * FROM orders WHERE id = $1", [orderId]);
  const items = await db.query("SELECT * FROM order_items WHERE order_id = $1", [orderId]);
  // Reconstruct domain object from raw rows...
  // Business logic mixed with SQL
}
 
// ✅ Repository returns domain objects
interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
  findByCustomer(customerId: string, status?: OrderStatus): Promise<Order[]>;
}
 
// Application service uses the repository
class OrderService {
  constructor(private orderRepo: OrderRepository) {}
 
  async submitOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error("Order not found");
 
    order.submit(); // Pure domain logic
 
    await this.orderRepo.save(order); // Persistence abstracted
  }
}

Key Takeaways

  1. Entities have identity, value objects have attributes — choose the right modeling tool for each concept
  2. Aggregates enforce consistency boundaries — all modifications go through the aggregate root
  3. Bounded contexts prevent monolithic models — the same concept means different things in different parts of the system
  4. Domain events enable context communication — publish events after saving, let other contexts react
  5. Repositories hide persistence details — domain objects shouldn't know about databases or ORMs
  6. DDD is for complex domains — simple CRUD applications don't benefit from this level of modeling
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX