Try free
8 min read Guide 856 of 877

Domain-Driven Design Patterns and Practices

Domain-Driven Design (DDD) is a software development approach that focuses on modeling complex business domains through strategic and tactical patterns. DDD helps teams create software that closely aligns with business needs while maintaining technical excellence and adaptability.

Strategic Domain-Driven Design

Bounded Contexts

Bounded contexts define the boundaries within which a particular domain model applies:

Context Mapping Patterns:

Sales Context ──► Customer Context ──► Support Context
     │                    │                    │
     └─ Shared Kernel ────┼─ Customer ─────────┘
                          │
                          └─ Separate Ways

Context Relationships:

  • Shared Kernel: Common subset of domain model shared between contexts
  • Customer-Supplier: One context depends on another
  • Conformist: Follows the model of another context
  • Anti-Corruption Layer: Translates between different models

Ubiquitous Language

Domain Expert Collaboration:

Business Analyst ──► Domain Expert ──► Developer ──► Code
     │                      │               │          │
     └─ Business Terms ─────┼─ Ubiquitous ──┼─ Domain ─┘
                            │   Language    │  Objects
                            └───────────────┘

Language Evolution:

  • Start with business terminology
  • Refine through conversations
  • Reflect changes in code immediately
  • Use the same terms in documentation, tests, and discussions

Tactical Domain-Driven Design

Domain Layer Patterns

Entities

Entities represent domain objects with identity and lifecycle:

Entity Characteristics:

class Customer {
  private readonly id: CustomerId;
  private name: PersonName;
  private email: EmailAddress;
  private status: CustomerStatus;
  private addresses: Address[];

  constructor(id: CustomerId, name: PersonName, email: EmailAddress) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.status = CustomerStatus.ACTIVE;
    this.addresses = [];
  }

  changeName(newName: PersonName): void {
    if (this.status !== CustomerStatus.ACTIVE) {
      throw new DomainError('Cannot change name of inactive customer');
    }
    this.name = newName;
    this.addDomainEvent(new CustomerNameChanged(this.id, newName));
  }

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

Value Objects

Value objects are immutable and defined by their attributes:

Value Object Implementation:

class Money {
  private readonly amount: number;
  private readonly currency: Currency;

  constructor(amount: number, currency: Currency) {
    if (amount < 0) {
      throw new DomainError('Amount cannot be negative');
    }
    this.amount = amount;
    this.currency = currency;
  }

  add(other: Money): Money {
    if (!this.currency.equals(other.currency)) {
      throw new DomainError('Cannot add 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.equals(other.currency);
  }
}

Aggregates

Aggregates define consistency boundaries and encapsulate business rules:

Aggregate Root Pattern:

class Order {
  private readonly id: OrderId;
  private readonly customerId: CustomerId;
  private status: OrderStatus;
  private items: OrderItem[];
  private totalAmount: Money;

  constructor(id: OrderId, customerId: CustomerId) {
    this.id = id;
    this.customerId = customerId;
    this.status = OrderStatus.DRAFT;
    this.items = [];
    this.totalAmount = Money.zero(Currency.USD);
  }

  addItem(productId: ProductId, quantity: number, unitPrice: Money): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new DomainError('Cannot modify confirmed order');
    }

    const existingItem = this.items.find(item => item.productId.equals(productId));
    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(new OrderItem(productId, quantity, unitPrice));
    }

    this.recalculateTotal();
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new DomainError('Cannot confirm empty order');
    }
    this.status = OrderStatus.CONFIRMED;
    this.addDomainEvent(new OrderConfirmed(this.id, this.totalAmount));
  }
}

Domain Services

Services contain business logic that doesn't naturally fit in entities or value objects:

Domain Service Example:

class PricingService {
  constructor(
    private readonly discountRepository: DiscountRepository,
    private readonly taxCalculator: TaxCalculator
  ) {}

  calculateTotal(order: Order, customer: Customer): Money {
    let subtotal = order.getSubtotal();

    // Apply customer discounts
    const customerDiscounts = this.discountRepository.findByCustomer(customer.id);
    for (const discount of customerDiscounts) {
      if (discount.appliesTo(subtotal)) {
        subtotal = discount.applyTo(subtotal);
      }
    }

    // Calculate taxes
    const taxAmount = this.taxCalculator.calculateTax(subtotal, customer.address);

    return subtotal.add(taxAmount);
  }
}

Domain Events

Domain events represent significant business occurrences:

Domain Event Pattern:

abstract class DomainEvent {
  public readonly eventId: string;
  public readonly aggregateId: string;
  public readonly eventVersion: number;
  public readonly occurredOn: Date;

  constructor(aggregateId: string) {
    this.eventId = uuidv4();
    this.aggregateId = aggregateId;
    this.eventVersion = 1;
    this.occurredOn = new Date();
  }
}

class OrderPlaced extends DomainEvent {
  constructor(
    public readonly orderId: OrderId,
    public readonly customerId: CustomerId,
    public readonly totalAmount: Money
  ) {
    super(orderId.toString());
  }
}

Application Layer

Command and Query Patterns

Command Pattern:

abstract class Command {
  public readonly commandId: string;
  public readonly timestamp: Date;

  constructor() {
    this.commandId = uuidv4();
    this.timestamp = new Date();
  }
}

class PlaceOrderCommand extends Command {
  constructor(
    public readonly customerId: CustomerId,
    public readonly items: OrderItem[]
  ) {
    super();
  }
}

class CommandHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly domainEventPublisher: DomainEventPublisher
  ) {}

  async handle(command: PlaceOrderCommand): Promise<OrderId> {
    const order = Order.create(command.customerId);

    for (const item of command.items) {
      order.addItem(item.productId, item.quantity, item.unitPrice);
    }

    await this.orderRepository.save(order);
    await this.domainEventPublisher.publish(order.getDomainEvents());

    return order.id;
  }
}

Application Services

Application Service Layer:

class OrderApplicationService {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus
  ) {}

  async placeOrder(request: PlaceOrderRequest): Promise<PlaceOrderResponse> {
    const command = new PlaceOrderCommand(
      new CustomerId(request.customerId),
      request.items.map(item => new OrderItem(
        new ProductId(item.productId),
        item.quantity,
        new Money(item.unitPrice, Currency.USD)
      ))
    );

    const orderId = await this.commandBus.send(command);

    return { orderId: orderId.toString() };
  }

  async getOrderDetails(orderId: string): Promise<OrderDetailsDto> {
    const query = new GetOrderDetailsQuery(new OrderId(orderId));
    return await this.queryBus.send(query);
  }
}

Infrastructure Layer

Repository Pattern

Repository Implementation:

interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findByCustomerId(customerId: CustomerId): Promise<Order[]>;
  nextIdentity(): OrderId;
}

class SqlOrderRepository implements OrderRepository {
  constructor(private readonly db: Database) {}

  async save(order: Order): Promise<void> {
    const data = this.mapToData(order);
    await this.db.orders.upsert({
      where: { id: order.id.toString() },
      update: data,
      create: data
    });
  }

  async findById(id: OrderId): Promise<Order | null> {
    const data = await this.db.orders.findUnique({
      where: { id: id.toString() }
    });

    return data ? this.mapToDomain(data) : null;
  }
}

Context Mapping Implementation

Anti-Corruption Layer

ACL Implementation:

class LegacySystemAdapter {
  constructor(private readonly legacyApi: LegacyApiClient) {}

  async getCustomer(customerId: string): Promise<Customer> {
    const legacyCustomer = await this.legacyApi.getCustomer(customerId);

    return new Customer(
      new CustomerId(legacyCustomer.id),
      new PersonName(legacyCustomer.firstName, legacyCustomer.lastName),
      new EmailAddress(legacyCustomer.email)
    );
  }

  async updateCustomer(customer: Customer): Promise<void> {
    await this.legacyApi.updateCustomer({
      id: customer.id.toString(),
      firstName: customer.name.firstName,
      lastName: customer.name.lastName,
      email: customer.email.toString()
    });
  }
}

Testing Domain Models

Unit Testing Domain Logic

Entity Testing:

describe('Customer', () => {
  it('should change name when active', () => {
    const customer = new Customer(
      new CustomerId('123'),
      new PersonName('John', 'Doe'),
      new EmailAddress('john@example.com')
    );

    customer.changeName(new PersonName('Jane', 'Doe'));

    expect(customer.name.firstName).toBe('Jane');
  });

  it('should not change name when inactive', () => {
    const customer = new Customer(/* ... */);
    customer.deactivate();

    expect(() => {
      customer.changeName(new PersonName('Jane', 'Doe'));
    }).toThrow(DomainError);
  });
});

Integration Testing

Repository Testing:

describe('OrderRepository', () => {
  let repository: OrderRepository;
  let db: TestDatabase;

  beforeEach(async () => {
    db = await createTestDatabase();
    repository = new SqlOrderRepository(db);
  });

  it('should save and retrieve order', async () => {
    const order = Order.create(new CustomerId('123'));
    order.addItem(new ProductId('456'), 2, new Money(10, Currency.USD));

    await repository.save(order);
    const retrieved = await repository.findById(order.id);

    expect(retrieved?.id).toEqual(order.id);
    expect(retrieved?.getTotalAmount()).toEqual(new Money(20, Currency.USD));
  });
});

Common DDD Anti-Patterns

Anemic Domain Model

Anti-Pattern:

// ❌ Anemic - no business logic
class Order {
  id: string;
  items: OrderItem[];
  status: string;

  // Just getters and setters
  getTotal(): number {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }
}

Correct Approach:

// ✅ Rich domain model
class Order {
  // Business logic encapsulated
  calculateTotal(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.getLineTotal()),
      Money.zero(this.currency)
    );
  }
}

God Classes

Anti-Pattern:

// ❌ God class - too many responsibilities
class OrderManager {
  createOrder() { /* ... */ }
  calculateTax() { /* ... */ }
  sendEmail() { /* ... */ }
  updateInventory() { /* ... */ }
}

Correct Approach:

// ✅ Single responsibility classes
class OrderFactory { /* ... */ }
class TaxCalculator { /* ... */ }
class EmailService { /* ... */ }
class InventoryService { /* ... */ }

GitScrum Integration

Domain Modeling in Task Management

DDD for Project Management:

  • Model projects as bounded contexts
  • Use domain events for task state changes
  • Implement aggregates for sprint and project boundaries

Ubiquitous Language in Team Communication

Shared Understanding:

  • Define clear domain terms for tasks and workflows
  • Use consistent terminology across team members
  • Reflect domain concepts in GitScrum board configurations