Skip to content

Domain-Driven Design: Introduction and Fundamentals

A comprehensive introduction to Domain-Driven Design: core concepts, building blocks, strategic patterns, and when and how to apply DDD in practice.

Ayhan Sipahi Ayhan Sipahi

Domain-Driven Design (DDD) aligns the structure of your code with the business domain it serves. In complex, long-lived systems that alignment pays off: when classes and methods carry the names domain experts already use, business rules stay in one place instead of leaking across controllers and services. The cost is modeling time spent with people who know the business.

That cost is the whole decision. Rules that change often, a system you will own for years, and domain experts willing to sit with you: those three conditions make the investment worth it. Without them, a plain layered architecture serves you better.

What is Domain-Driven Design?#

Domain-Driven Design, introduced by Eric Evans in 2003, is an approach to software development that emphasizes collaboration between technical experts and domain experts. The core idea: your code should reflect the business domain it serves, using the same language and concepts that domain experts use.

That collaboration produces what DDD calls the ubiquitous language: shared vocabulary that shows up in code, conversations, and documentation alike. When code and business talk in the same terms, misunderstandings drop and the software stays easier to maintain.

Here’s what DDD focuses on:

  • Ubiquitous Language: A common vocabulary shared between developers and domain experts
  • Model-Driven Design: Code structure that mirrors the business domain
  • Bounded Contexts: Clear boundaries between different parts of the system
  • Strategic Design: High-level patterns for organizing large systems
  • Tactical Design: Concrete building blocks for implementing domain logic

When to Use DDD (and When Not To)#

DDD is powerful, but it’s not a universal solution.

Use DDD When#

Business rules that change frequently are the strongest signal: codebases where logic is scattered across controllers and services are common, and DDD brings structure to that chaos. It also pays off on systems you’ll maintain for years, since the upfront modeling investment speeds up onboarding and lowers the risk of breaking rules during later changes.

Collaboration matters just as much. Domain experts who are available and willing to collaborate produce a shared language and model that ad hoc development rarely reaches on its own. The same reasoning extends to systems built from distinct subdomains: e-commerce split across inventory, payments, and shipping is a typical example where DDD’s strategic patterns manage the boundaries between contexts.

Skip DDD When#

A straightforward data entry system with minimal business logic doesn’t need DDD: a basic MVC or layered architecture works fine, and the extra structure only adds complexity. The same goes for prototypes and MVPs, where the modeling overhead slows down the feedback loop you actually need, so validate first and reconsider only if the project grows. Data-centric work is the other exception: ETL pipelines, reporting tools, and analytics systems are usually better served by data-oriented approaches rather than domain modeling. A small team without access to domain experts rarely has enough to justify the investment either.

Tactical Design Patterns#

These are the concrete building blocks you’ll use in code: the tactical half of DDD, distinct from the strategic patterns covered further down.

Entities#

Entities are objects with a unique identity that persists over time. Two entities with the same data but different IDs are distinct objects.

// Entity: User with unique identity
class User {
  private constructor(
    private readonly id: string,
    private email: string,
    private name: string,
    private registeredAt: Date
  ) {}

  static create(email: string, name: string): User {
    // Validation logic
    if (!email.includes('@')) {
      throw new Error('Invalid email format');
    }

    return new User(
      crypto.randomUUID(),
      email,
      name,
      new Date()
    );
  }

  static reconstitute(
    id: string,
    email: string,
    name: string,
    registeredAt: Date
  ): User {
    return new User(id, email, name, registeredAt);
  }

  changeEmail(newEmail: string): void {
    if (!newEmail.includes('@')) {
      throw new Error('Invalid email format');
    }
    this.email = newEmail;
  }

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

  getEmail(): string {
    return this.email;
  }

  equals(other: User): boolean {
    return this.id === other.id;
  }
}

The id field gives this entity its identity: two User instances with the same email but different IDs are still different users. Entities move through a lifecycle (created, modified, eventually deleted), and factory methods make that lifecycle explicit: create() builds a new instance, reconstitute() rebuilds one from storage. Business rules such as email validation live inside changeEmail().

Value Objects#

Value Objects represent concepts without identity. Two value objects with the same data are considered equal.

// Value Object: Email address
class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email {
    if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
      throw new Error('Invalid email format');
    }
    return new Email(email.toLowerCase());
  }

  getValue(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  getDomain(): string {
    return this.value.split('@')[1];
  }
}

// Value Object: Money with currency
class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
    return new Money(amount, currency.toUpperCase());
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Cannot add money with different currencies');
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }

  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }

  getAmount(): number {
    return this.amount;
  }

  getCurrency(): string {
    return this.currency;
  }
}

Value objects are immutable: there are no setters, and operations like add() and multiply() return new instances instead of mutating the original. They’re also self-validating, failing construction immediately when data is invalid the way Email.create() and Money.create() do above, and they compare by value: two Money objects with the same amount and currency are equal even though they are different instances.

Aggregates#

Aggregates are clusters of entities and value objects with a clear boundary and a single root entity. The aggregate root enforces consistency rules.

// Aggregate: Order with OrderItems
class OrderItem {
  constructor(
    private readonly productId: string,
    private readonly productName: string,
    private readonly price: Money,
    private quantity: number
  ) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }
  }

  getTotal(): Money {
    return this.price.multiply(this.quantity);
  }

  changeQuantity(newQuantity: number): void {
    if (newQuantity <= 0) {
      throw new Error('Quantity must be positive');
    }
    this.quantity = newQuantity;
  }

  getProductId(): string {
    return this.productId;
  }

  getQuantity(): number {
    return this.quantity;
  }
}

// Aggregate Root
class Order {
  private items: OrderItem[] = [];
  private status: 'draft' | 'confirmed' | 'shipped' | 'cancelled' = 'draft';

  private constructor(
    private readonly id: string,
    private readonly customerId: string,
    private readonly createdAt: Date
  ) {}

  static create(customerId: string): Order {
    return new Order(crypto.randomUUID(), customerId, new Date());
  }

  addItem(productId: string, productName: string, price: Money, quantity: number): void {
    if (this.status !== 'draft') {
      throw new Error('Cannot modify confirmed order');
    }

    // Check if item already exists
    const existingItem = this.items.find(item => item.getProductId() === productId);
    if (existingItem) {
      existingItem.changeQuantity(existingItem.getQuantity() + quantity);
    } else {
      this.items.push(new OrderItem(productId, productName, price, quantity));
    }
  }

  removeItem(productId: string): void {
    if (this.status !== 'draft') {
      throw new Error('Cannot modify confirmed order');
    }
    this.items = this.items.filter(item => item.getProductId() !== productId);
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    if (this.status !== 'draft') {
      throw new Error('Order already confirmed');
    }
    this.status = 'confirmed';
  }

  cancel(): void {
    if (this.status === 'shipped') {
      throw new Error('Cannot cancel shipped order');
    }
    this.status = 'cancelled';
  }

  getTotal(): Money {
    if (this.items.length === 0) {
      return Money.create(0, 'USD');
    }
    return this.items.reduce(
      (total, item) => total.add(item.getTotal()),
      Money.create(0, 'USD')
    );
  }

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

  getItems(): readonly OrderItem[] {
    return [...this.items];
  }

  getStatus(): string {
    return this.status;
  }
}

Only Order is referenced from outside the aggregate; OrderItem stays internal, reachable only through the root. That root is where consistency rules live: addItem(), confirm(), and cancel() each check the current state before making a change, so the aggregate never sits in an invalid state between operations. Changes commit atomically, and other aggregates reference this one by ID rather than holding a direct object reference.

Repositories#

Repositories provide an abstraction for accessing aggregates, hiding persistence details.

// Repository interface
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(orderId: string): Promise<Order | null>;
  findByCustomer(customerId: string): Promise<Order[]>;
  delete(orderId: string): Promise<void>;
}

// In-memory implementation for testing
class InMemoryOrderRepository implements OrderRepository {
  private orders = new Map<string, Order>();

  async save(order: Order): Promise<void> {
    this.orders.set(order.getId(), order);
  }

  async findById(orderId: string): Promise<Order | null> {
    return this.orders.get(orderId) || null;
  }

  async findByCustomer(customerId: string): Promise<Order[]> {
    return Array.from(this.orders.values()).filter(
      order => order['customerId'] === customerId
    );
  }

  async delete(orderId: string): Promise<void> {
    this.orders.delete(orderId);
  }
}

// PostgreSQL implementation
class PostgresOrderRepository implements OrderRepository {
  constructor(private db: any) {} // Your database client

  async save(order: Order): Promise<void> {
    await this.db.transaction(async (trx: any) => {
      // Save order
      await trx('orders').insert({
        id: order.getId(),
        customer_id: order['customerId'],
        status: order.getStatus(),
        created_at: order['createdAt']
      }).onConflict('id').merge();

      // Save order items
      await trx('order_items').where('order_id', order.getId()).delete();

      const items = order.getItems().map(item => ({
        order_id: order.getId(),
        product_id: item.getProductId(),
        quantity: item.getQuantity(),
        // ... other fields
      }));

      if (items.length > 0) {
        await trx('order_items').insert(items);
      }
    });
  }

  async findById(orderId: string): Promise<Order | null> {
    const orderData = await this.db('orders')
      .where('id', orderId)
      .first();

    if (!orderData) return null;

    const itemsData = await this.db('order_items')
      .where('order_id', orderId);

    // Reconstitute aggregate from data
    return this.reconstitute(orderData, itemsData);
  }

  async findByCustomer(customerId: string): Promise<Order[]> {
    const ordersData = await this.db('orders')
      .where('customer_id', customerId);

    return Promise.all(
      ordersData.map((data: any) => this.findById(data.id))
    );
  }

  async delete(orderId: string): Promise<void> {
    await this.db.transaction(async (trx: any) => {
      await trx('order_items').where('order_id', orderId).delete();
      await trx('orders').where('id', orderId).delete();
    });
  }

  private reconstitute(orderData: any, itemsData: any[]): Order {
    // Reconstruct Order aggregate from database data
    // This would use a static reconstitute method on Order
    // Implementation details omitted for brevity
    throw new Error('Not implemented');
  }
}

That abstraction behaves like a collection: save(), findById(), delete(), nothing that leaks SQL or table names. The domain layer stays ignorant of persistence, Order never imports a database client, and repositories stay aggregate-oriented, one per aggregate root. That same ignorance is what makes InMemoryOrderRepository a viable stand-in for PostgresOrderRepository in tests.

Domain Services#

Domain services contain business logic that doesn’t naturally fit within an entity or value object. They’re stateless operations on domain objects.

// Domain Service: Pricing calculation
class PricingService {
  calculateDiscount(order: Order, customer: Customer): Money {
    const total = order.getTotal();

    // VIP customers get 10% discount
    if (customer.isVIP()) {
      return total.multiply(0.1);
    }

    // Orders over $500 get 5% discount
    if (total.getAmount() >= 500) {
      return total.multiply(0.05);
    }

    return Money.create(0, total.getCurrency());
  }

  applySeasonalPricing(
    basePrice: Money,
    season: 'peak' | 'regular' | 'off-peak'
  ): Money {
    switch (season) {
      case 'peak':
        return basePrice.multiply(1.3);
      case 'off-peak':
        return basePrice.multiply(0.7);
      default:
        return basePrice;
    }
  }
}

// Domain Service: Order fulfillment coordination
class OrderFulfillmentService {
  constructor(
    private inventoryService: InventoryService,
    private shippingService: ShippingService
  ) {}

  async fulfillOrder(order: Order): Promise<void> {
    // Verify inventory
    for (const item of order.getItems()) {
      const available = await this.inventoryService.checkAvailability(
        item.getProductId(),
        item.getQuantity()
      );

      if (!available) {
        throw new Error(`Product ${item.getProductId()} not available`);
      }
    }

    // Reserve inventory
    for (const item of order.getItems()) {
      await this.inventoryService.reserve(
        item.getProductId(),
        item.getQuantity(),
        order.getId()
      );
    }

    // Arrange shipping
    await this.shippingService.createShipment(order);
  }
}

OrderFulfillmentService above is a typical case: it touches inventory and shipping, two concerns that don’t belong to Order itself. Domain services earn their place for logic that spans multiple aggregates, coordinates external systems, or runs a calculation that doesn’t fit naturally on any single entity. They hold no state of their own, only transformations over the objects passed in.

Strategic Design Patterns#

Strategic DDD patterns help organize large systems and manage complexity at a higher level.

Ubiquitous Language#

Ubiquitous Language is the shared vocabulary between developers and domain experts. This language appears in code, conversations, documentation, and tests.

When the code uses different terms than business stakeholders, translation errors creep in. If the business calls it “reservation” but the code calls it “booking,” misunderstood requirements follow.

Bad Example (Generic technical terms):

class DataManager {
  processRequest(data: any): any {
    // What does "process" mean in business terms?
  }
}

Good Example (Ubiquitous Language):

class ReservationService {
  confirmReservation(reservation: Reservation): void {
    // Clear business operation
  }

  cancelReservation(reservationId: string): void {
    // Business stakeholders understand this
  }
}

In practice, that means sitting down with domain experts in collaborative modeling sessions, keeping a shared glossary that both sides actually use, and naming things the same way in code, docs, and conversation, refining the vocabulary as the team’s understanding of the domain deepens.

Bounded Contexts#

A Bounded Context is an explicit boundary within which a domain model is defined and applicable. Different contexts can have different models for the same concept.

Consider “Customer” in an e-commerce system:

Billing Context

has

has

Customer

PaymentMethods

InvoiceHistory

Support Context

has

has

Customer

SupportTickets

ContactPreferences

Sales Context

has

has

Customer

OrderHistory

ShippingAddress

In code, these might look different:

// Sales Context - Customer focused on purchasing
namespace SalesContext {
  export class Customer {
    constructor(
      private readonly id: string,
      private readonly email: string,
      private shippingAddresses: Address[],
      private orderHistory: Order[]
    ) {}

    placeOrder(order: Order): void {
      this.orderHistory.push(order);
    }

    getPreferredShippingAddress(): Address {
      // Business logic for sales
      return this.shippingAddresses[0];
    }
  }
}

// Support Context - Customer focused on service issues
namespace SupportContext {
  export class Customer {
    constructor(
      private readonly id: string,
      private readonly email: string,
      private tickets: SupportTicket[],
      private preferredContactMethod: 'email' | 'phone'
    ) {}

    createTicket(issue: string): SupportTicket {
      const ticket = new SupportTicket(issue, this.id);
      this.tickets.push(ticket);
      return ticket;
    }

    getOpenTickets(): SupportTicket[] {
      return this.tickets.filter(t => t.isOpen());
    }
  }
}

The Sales and Support Customer classes above stay small because each one models only what its context needs: Sales never has to think about support tickets, Support never has to think about shipping addresses. That focus lets contexts evolve independently and gives teams clear ownership of the model they work in.

Context Mapping#

Context Mapping defines relationships between bounded contexts. Here are common patterns:

Customer/Supplier

Shared Kernel

Conformist

Anti-Corruption Layer

Published Language

Sales Context

Inventory Context

Pricing Context

Billing Context

Analytics Context

Support Context

In a Customer/Supplier relationship, the downstream context depends on the upstream one, and teams negotiate changes:

// Sales Context (Upstream)
interface OrderPlaced {
  orderId: string;
  items: { productId: string; quantity: number }[];
}

// Inventory Context (Downstream)
class InventoryService {
  handleOrderPlaced(event: OrderPlaced): void {
    // Reserve inventory based on order
    event.items.forEach(item => {
      this.reserveStock(item.productId, item.quantity);
    });
  }
}

An Anti-Corruption Layer protects your model from external system concepts:

// External legacy system has different model
interface LegacyCustomerDTO {
  cust_id: number;
  cust_name: string;
  cust_email: string;
  // Many other fields we don't need
}

// Anti-Corruption Layer
class LegacyCustomerAdapter {
  toDomainModel(dto: LegacyCustomerDTO): Customer {
    return new Customer(
      dto.cust_id.toString(),
      Email.create(dto.cust_email),
      dto.cust_name
    );
  }

  toDTO(customer: Customer): LegacyCustomerDTO {
    return {
      cust_id: parseInt(customer.getId()),
      cust_name: customer.getName(),
      cust_email: customer.getEmail().getValue()
    };
  }
}

With a Shared Kernel, two contexts share a subset of the domain model, so changes require coordination:

// Shared kernel between Sales and Pricing
namespace SharedKernel {
  export class Money {
    // Shared implementation
  }

  export class ProductId {
    // Shared value object
  }
}

Where DDD Projects Go Wrong#

The same handful of mistakes shows up across DDD codebases:

Anemic Domain Models#

When entities expose only getters and setters, the logic drifts into services and the entity itself carries no behavior:

// Anemic - Don't do this
class Order {
  public id: string;
  public items: OrderItem[];
  public status: string;

  // Just getters and setters, no behavior
}

class OrderService {
  placeOrder(order: Order): void {
    // All business logic here instead of in Order
    if (order.items.length === 0) {
      throw new Error('Empty order');
    }
    order.status = 'placed';
  }
}

Moving that behavior back into the entity keeps the domain model meaningful:

// Rich domain model
class Order {
  private status: OrderStatus;
  private items: OrderItem[];

  place(): void {
    if (this.items.length === 0) {
      throw new Error('Cannot place empty order');
    }
    this.status = OrderStatus.Placed;
  }
}

Over-Engineering Simple Domains#

Applying the full DDD toolkit to simple CRUD operations is its own kind of mistake. A basic address book doesn’t need aggregates, repositories, and domain services: a plain data model with validation covers it.

Ignoring Context Boundaries#

Treating the entire system as one large model leads to god objects: a Customer with 50 properties trying to serve every use case. Sales, support, and billing each need their own slice of what “customer” means.

Repository as Database Gateway#

A repository that grows a query method for every use case stops being a repository and turns into an ad-hoc database gateway:

// Too many query methods
interface OrderRepository {
  findById(id: string): Promise<Order>;
  findByCustomerId(customerId: string): Promise<Order[]>;
  findByStatus(status: string): Promise<Order[]>;
  findByDateRange(start: Date, end: Date): Promise<Order[]>;
  findByCustomerAndStatus(customerId: string, status: string): Promise<Order[]>;
  // ... 20 more methods
}

Keeping repositories focused on aggregate roots and pushing the rest into a separate read model for queries keeps that growth in check:

// Simple repository
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
  delete(id: string): Promise<void>;
}

// Separate query service for reads
interface OrderQueryService {
  searchOrders(criteria: OrderSearchCriteria): Promise<OrderDTO[]>;
}

Large Aggregates#

Aggregates that keep growing cause their own performance problems. If your Order aggregate includes customer details, shipping information, payment history, and product catalogs, you’ll load too much data for simple operations.

Keeping aggregates small and referencing other aggregates by ID avoids that:

class Order {
  constructor(
    private readonly id: string,
    private readonly customerId: string, // Reference by ID
    private items: OrderItem[]
  ) {}
}

Skipping Ubiquitous Language#

Developers sometimes invent their own technical terms instead of using the business’s language, and that mismatch creates a translation layer where bugs hide. When code says “transaction processing” but the business says “payment confirmation,” misunderstandings follow, and collaborative modeling sessions close that gap.

Adopting DDD Incrementally#

Adopting DDD doesn’t require rewriting everything at once; a few habits make the transition easier.

Pick one complex subdomain and apply DDD there first, then use what you learn on it before expanding to the rest of the system. Event storming sessions, where developers and domain experts map out business processes together with sticky notes, surface the ubiquitous language and bounded contexts on their own. Writing tests in business language works the same way: it reinforces the domain model and catches the moment code drifts from business intent. The Order tests below check both an invalid state and a total calculation:

describe('Order', () => {
  it('should prevent confirmation of empty orders', () => {
    const order = Order.create('customer-123');

    expect(() => order.confirm()).toThrow('Cannot confirm empty order');
  });

  it('should calculate correct total with multiple items', () => {
    const order = Order.create('customer-123');
    order.addItem('product-1', 'Widget', Money.create(10, 'USD'), 2);
    order.addItem('product-2', 'Gadget', Money.create(15, 'USD'), 1);

    expect(order.getTotal().getAmount()).toBe(35);
  });
});

Domain logic can move from services into entities the same incremental way, each extraction making the next a little easier. Types and interfaces can document the domain concepts as that migration proceeds.

Resources and Further Reading#

To deepen your understanding of DDD, here are the essential resources:

Books:

  • “Domain-Driven Design” by Eric Evans - The original blue book. Dense but comprehensive. Start with Part II on building blocks.
  • “Implementing Domain-Driven Design” by Vaughn Vernon - More practical and modern. Excellent for implementation guidance.
  • “Domain-Driven Design Distilled” by Vaughn Vernon - Condensed introduction, good for getting started quickly.

Online Resources:

Practical Examples:

Conclusion#

Domain-Driven Design gives you two toolsets. The tactical patterns (entities, value objects, aggregates, repositories, and domain services) shape individual classes into a clean domain model. The strategic patterns (ubiquitous language, bounded contexts, and context mapping) decide how a large system is divided.

The default holds when business rules are complex, the system will live for years, and domain experts are available to model with you. Override it when the domain is thin: a CRUD admin panel, a reporting pipeline, or a throwaway prototype moves slower under aggregates and repositories without getting safer. If you are unsure, apply DDD to one complex subdomain first and keep the rest simple.

References#

Related posts