Skip to content

Domain-Driven Design: Introduction and Fundamentals

A comprehensive introduction to Domain-Driven Design - core concepts, building blocks, strategic patterns, and practical guidance on when and how to apply DDD in software development

Abstract

Domain-Driven Design (DDD) is a strategic approach to building complex software systems by aligning the code structure with business domain logic. This guide explores DDD's core concepts, building blocks, and strategic patterns, with practical TypeScript examples showing when and how to apply these principles effectively.

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.

Working with DDD taught me that it's not just about technical patterns - it's about creating a shared understanding between developers and business stakeholders. When your code speaks the same language as your business, communication improves and the software becomes more maintainable.

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. Here's what I've learned about when it makes sense.

Use DDD When:

Complex Business Logic: If your application has intricate business rules that change frequently, DDD helps manage that complexity. I've seen codebases where business logic was scattered across controllers and services - DDD brings structure to this chaos.

Long-Term Projects: For systems you'll maintain for years, the upfront investment in DDD modeling pays off. The explicit domain model makes onboarding new developers faster and reduces the risk of breaking business rules during changes.

Collaborative Environments: When domain experts are available and willing to collaborate, DDD shines. The shared language and model create alignment that traditional development approaches struggle to achieve.

Multiple Bounded Contexts: Systems with distinct subdomains (e.g., e-commerce with inventory, payments, shipping) benefit from DDD's strategic patterns for managing boundaries and relationships.

Skip DDD When:

Simple CRUD Applications: If you're building a straightforward data entry system with minimal business logic, DDD adds unnecessary complexity. A basic MVC or layered architecture works fine.

Prototypes and MVPs: For quick validation projects, DDD's modeling overhead slows you down. Get feedback first, then consider DDD if the project grows.

Data-Centric Systems: ETL pipelines, reporting tools, and analytics systems are often better served by data-oriented approaches rather than domain modeling.

Small Teams Without Domain Expertise: If you can't access domain experts or your team is too small to justify the modeling investment, simpler approaches work better.

Core Building Blocks

Let's explore the tactical patterns that make up DDD's building blocks. These are the concrete implementations you'll use in your code.

Entities

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

typescript
// Entity: User with unique identityclass 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;  }}

Key characteristics:

  • Identity: The id field uniquely identifies the user
  • Lifecycle: Entities are created, modified, and eventually may be deleted
  • Factory methods: create() for new entities, reconstitute() for loading from storage
  • Business logic: Methods like changeEmail() enforce domain rules

Value Objects

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

typescript
// Value Object: Email addressclass 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 currencyclass 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: No setters, operations return new instances
  • Self-validating: Construction fails if data is invalid
  • Replaceable: You don't modify them, you replace them with new instances
  • Comparable by value: Equality is based on data, not identity

Aggregates

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

typescript
// Aggregate: Order with OrderItemsclass 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 Rootclass 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;  }}

Aggregate principles:

  • Single Root: Only Order is referenced from outside; OrderItem is internal
  • Consistency Boundary: All business rules are enforced by the aggregate root
  • Transactional: Changes to an aggregate should be committed atomically
  • Reference by ID: Other aggregates reference this one by ID, not direct object reference

Repositories

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

typescript
// Repository interfaceinterface 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 testingclass 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 implementationclass 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');  }}

Repository patterns:

  • Collection-like interface: Think of it as an in-memory collection
  • Persistence ignorance: Domain layer doesn't know about database details
  • Aggregate-oriented: One repository per aggregate root
  • Testability: Easy to swap implementations for testing

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.

typescript
// Domain Service: Pricing calculationclass 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 coordinationclass 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);  }}

When to use domain services:

  • Cross-aggregate operations: Logic involving multiple aggregates
  • External system coordination: Orchestrating multiple domain operations
  • Complex calculations: Business logic that uses multiple entities but doesn't belong to one
  • Stateless operations: No internal state, just transformations

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.

Here's what I've learned: when your code uses different terms than your business stakeholders, translation errors creep in. If the business calls it "reservation" but your code calls it "booking," someone will misunderstand the requirements.

Bad Example (Generic technical terms):

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

Good Example (Ubiquitous Language):

typescript
class ReservationService {  confirmReservation(reservation: Reservation): void {    // Clear business operation  }
  cancelReservation(reservationId: string): void {    // Business stakeholders understand this  }}

In practice, building ubiquitous language means:

  • Collaborative modeling sessions with domain experts
  • Glossary of terms shared between business and tech
  • Consistent naming across code, docs, and conversations
  • Refinement over time as understanding 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:

In code, these might look different:

typescript
// Sales Context - Customer focused on purchasingnamespace 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 issuesnamespace 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());    }  }}

Benefits of bounded contexts:

  • Focused models: Each context has only what it needs
  • Independent evolution: Contexts can change without affecting others
  • Clear ownership: Teams own specific contexts
  • Reduced coupling: Dependencies are explicit at context boundaries

Context Mapping

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

Customer/Supplier: Downstream context depends on upstream. Teams negotiate changes.

typescript
// 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);    });  }}

Anti-Corruption Layer: Protects your model from external system concepts.

typescript
// External legacy system has different modelinterface LegacyCustomerDTO {  cust_id: number;  cust_name: string;  cust_email: string;  // Many other fields we don't need}
// Anti-Corruption Layerclass 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()    };  }}

Shared Kernel: Two contexts share a subset of the domain model. Changes require coordination.

typescript
// Shared kernel between Sales and Pricingnamespace SharedKernel {  export class Money {    // Shared implementation  }
  export class ProductId {    // Shared value object  }}

Common Pitfalls and How to Avoid Them

Working with DDD, I've encountered these issues repeatedly:

Anemic Domain Models

Problem: Entities become data containers with getters/setters, all logic in services.

typescript
// Anemic - Don't do thisclass 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';  }}

Solution: Move behavior into the domain model.

typescript
// Rich domain modelclass 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

Problem: Applying full DDD to simple CRUD operations.

If you're building a basic address book, you don't need aggregates, repositories, and domain services. A simple data model with validation works fine. Save DDD for complex business logic.

Ignoring Context Boundaries

Problem: Treating the entire system as one large model.

This leads to god objects like Customer with 50 properties trying to serve every use case. Instead, recognize that "customer" means different things in sales, support, and billing contexts.

Repository as Database Gateway

Problem: Repositories that expose query methods for every use case.

typescript
// Too many query methodsinterface 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}

Solution: Keep repositories focused on aggregate roots. Use separate read models for queries.

typescript
// Simple repositoryinterface OrderRepository {  save(order: Order): Promise<void>;  findById(id: string): Promise<Order | null>;  delete(id: string): Promise<void>;}
// Separate query service for readsinterface OrderQueryService {  searchOrders(criteria: OrderSearchCriteria): Promise<OrderDTO[]>;}

Large Aggregates

Problem: Aggregates that grow too large, causing performance issues.

If your Order aggregate includes customer details, shipping information, payment history, and product catalogs, you'll load too much data for simple operations.

Solution: Keep aggregates small and focused. Reference other aggregates by ID.

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

Skipping Ubiquitous Language

Problem: Developers create their own technical terms instead of using business language.

This creates a translation layer where bugs hide. When code says "transaction processing" but the business says "payment confirmation," misunderstandings occur.

Solution: Invest time in collaborative modeling sessions. Your code should use the exact terms your domain experts use.

Practical Implementation Tips

Here's what works in practice:

Start Small: Don't boil the ocean. Pick one complex subdomain and apply DDD there. Learn what works for your team before expanding.

Event Storming: Run collaborative sessions where developers and domain experts map out business processes with sticky notes. This surfaces the ubiquitous language and bounded contexts naturally.

Test-Driven Development: Write tests in business language. This reinforces the domain model and catches when code diverges from business intent.

typescript
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);  });});

Incremental Refactoring: You don't need to rewrite everything. Extract domain logic from services into entities gradually. Each improvement makes the next one easier.

Documentation as Code: Use types and interfaces to document domain concepts. Self-documenting code reduces the need for external docs that get stale.

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:

  • Martin Fowler's articles on martinfowler.com - Clear explanations of key patterns
  • DDD Community at ddd-community.org - Active community with resources and events
  • EventStorming website by Alberto Brandolini - Collaborative modeling technique

Practical Examples:

Conclusion

Domain-Driven Design provides powerful tools for managing complexity in software systems. The tactical patterns - entities, value objects, aggregates, repositories, and domain services - give you building blocks for clean domain models. The strategic patterns - ubiquitous language, bounded contexts, and context mapping - help organize large systems.

Here's what I've learned matters most: DDD isn't about blindly applying patterns. It's about creating a shared understanding between business and technology, then expressing that understanding in code. When your code speaks the business language, when your models reflect how domain experts think about problems, software becomes easier to understand, maintain, and evolve.

Start with one complex subdomain. Collaborate with domain experts. Build ubiquitous language together. Apply the tactical patterns where they add value. Recognize bounded contexts as your system grows. DDD is a journey, not a destination - your understanding will deepen as you apply these principles in real projects.

Related Posts