Skip to content

✍️ 필사 모드: DDD (Domain-Driven Design) Complete Guide 2025: Strategic/Tactical Patterns, Bounded Context, Event Storming

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Table of Contents

1. Why DDD: A Structured Approach to Complex Domains

The essential complexity of software comes not from technology but from the domain. DDD, proposed by Eric Evans in his 2003 book "Domain-Driven Design: Tackling Complexity in the Heart of Software," is a design philosophy that confronts this complexity head-on.

1.1 Limitations of Traditional Approaches

Many projects start by designing the database schema first. Create tables, write CRUD APIs, then attach the UI. While this works for simple applications, it hits a wall when the domain becomes complex.

Symptoms:

  • Business logic scattered across various Service classes
  • Developers and domain experts speak different languages
  • The word "order" carries different meanings for the sales team, logistics team, and payment team
  • A single change cascades to unexpected places
  • The model keeps growing until nobody understands the whole picture

1.2 What DDD Solves

DDD addresses complexity at two levels:

LevelConcernKey Tools
Strategic DesignBig picture, team boundariesBounded Context, Context Map, Ubiquitous Language
Tactical DesignCode-level modelingEntity, Value Object, Aggregate, Repository, Domain Event

1.3 Projects Where DDD Fits

DDD is not a silver bullet. Use these criteria to decide:

  • Good fit: Complex business rules, multiple subdomains, need for close collaboration with domain experts
  • Poor fit: Simple CRUD, tech-centric projects (file converters, etc.), prototypes/MVPs

2. Strategic Patterns: Designing the Big Picture

Strategic patterns address how to divide the system and define relationships between teams.

2.1 Ubiquitous Language

The most fundamental concept in DDD. The development team and domain experts must use the same terminology.

Bad example:

  • Domain expert: "When the customer places an order..."
  • Developer code: user.createRequest()

Good example:

  • Domain expert: "When the customer places an order..."
  • Developer code: customer.placeOrder()

Ubiquitous Language must be consistent across all of these:

  1. Code (class names, method names, variable names)
  2. Database schema
  3. API endpoints
  4. Documentation
  5. Team conversations
// Bad: tech-centric naming
class DataProcessor {
  processRecord(data: Record<string, unknown>): void {
    // ...
  }
}

// Good: reflecting Ubiquitous Language
class OrderFulfillmentService {
  fulfillOrder(order: Order): FulfillmentResult {
    // ...
  }
}

2.2 Bounded Context

The same term can have different meanings in different contexts. Think about the word "Product":

  • Catalog Context: name, description, images, category
  • Inventory Context: SKU, quantity, warehouse location
  • Pricing Context: base price, discount rate, promotion rules
  • Shipping Context: weight, dimensions, shipping restrictions

Representing everything with a single massive Product model couples all teams. Bounded Context solves this problem.

┌─────────────────┐  ┌─────────────────┐
Catalog Context  │  │ Inventory Context│
│                  │  │                  │
Product:        │  │  Product:- name          │  │  - sku           │
- description   │  │  - quantity      │
- images        │  │  - warehouse     │
- category      │  │  - reorderLevel  │
└─────────────────┘  └─────────────────┘

┌─────────────────┐  ┌─────────────────┐
Pricing Context  │  │ Shipping Context│                  │  │                  │
Product:        │  │  Product:- basePrice     │  │  - weight        │
- discount      │  │  - dimensions    │
- promotions    │  │  - restrictions  │
└─────────────────┘  └─────────────────┘

2.3 Context Map

Defines the relationships between Bounded Contexts. There are 7 relationship patterns:

Partnership

Two teams collaborate closely and succeed or fail together.

[Order Context] <--> [Payment Context]
           Partnership

Shared Kernel

Two contexts share a part of a common model. Changes require mutual agreement.

[Context A] -- Shared Kernel -- [Context B]
                (shared module)

Customer-Supplier

The upstream (supplier) accommodates the downstream (customer) needs.

[Order Context]  -->  [Shipping Context]
  (Upstream)            (Downstream)
   Supplier              Customer

Conformist

The downstream adopts the upstream model as-is. Happens when there is no negotiation power.

[External Payment API]  -->  [Our Payment Integration]
     (Upstream)                  (Downstream)
                                  Conformist

Anti-Corruption Layer (ACL)

The downstream places a translation layer to protect its own model.

// Anti-Corruption Layer example
class ExternalPaymentACL {
  private externalClient: ExternalPaymentClient;

  async processPayment(domainPayment: Payment): Promise<PaymentResult> {
    // Our domain model -> external API model
    const externalRequest = {
      amt: domainPayment.amount.value,
      ccy: domainPayment.amount.currency.code,
      merchant_ref: domainPayment.orderId.toString(),
      card_tkn: domainPayment.paymentMethod.token,
    };

    const externalResponse = await this.externalClient.charge(externalRequest);

    // External API model -> our domain model
    return new PaymentResult(
      externalResponse.status === 'OK'
        ? PaymentStatus.APPROVED
        : PaymentStatus.DECLINED,
      new TransactionId(externalResponse.txn_id)
    );
  }
}

Open Host Service (OHS)

The upstream provides a well-defined protocol (API) for multiple downstream consumers.

               [Downstream A]
              /
[OHS API] -->  [Downstream B]
              \
               [Downstream C]

Published Language

Standardizes the exchange format between contexts. JSON Schema, Protobuf, Avro, etc.

// Published Language example: Protocol Buffers
syntax = "proto3";

message OrderPlacedEvent {
  string order_id = 1;
  string customer_id = 2;
  repeated OrderLineItem items = 3;
  Money total_amount = 4;
  google.protobuf.Timestamp placed_at = 5;
}

2.4 Context Map Diagram Example

Full Context Map for an e-commerce system:

┌──────────────┐    Partnership     ┌──────────────┐
Identity<-------------------->CustomerContext    │                    │   Context└──────┬───────┘                    └──────┬───────┘
       │                                   │
OHS/PLCustomer-Supplier
       │                                   │
       v                                   v
┌──────────────┐  Customer-Supplier ┌──────────────┐
Catalog------------------->OrderContext    │                    │   Context└──────────────┘                    └──────┬───────┘
                          ┌────────────────┼────────────────┐
                          │                │                │
                          v                v                v
                   ┌────────────┐  ┌────────────┐  ┌────────────┐
Payment   │  │  Inventory │  │  ShippingContext   │  │  Context   │  │  Context                   └─────┬──────┘  └────────────┘  └────────────┘
ACL
                         v
                   ┌────────────┐
ExternalPayment GW                   └────────────┘

3. Tactical Patterns: Code-Level Modeling

Tactical patterns are the concrete methods for implementing domain models within a Bounded Context.

3.1 Entity

A domain object distinguished by a unique identifier. Even if all attributes change, it remains the same object as long as the identifier stays the same.

// Entity example: Order
class Order {
  private readonly id: OrderId;
  private status: OrderStatus;
  private items: OrderLineItem[];
  private readonly customerId: CustomerId;
  private readonly placedAt: Date;

  constructor(
    id: OrderId,
    customerId: CustomerId,
    items: OrderLineItem[]
  ) {
    if (items.length === 0) {
      throw new EmptyOrderError();
    }
    this.id = id;
    this.customerId = customerId;
    this.items = items;
    this.status = OrderStatus.PLACED;
    this.placedAt = new Date();
  }

  get totalAmount(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.zero('KRW')
    );
  }

  confirm(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new InvalidOrderStateError(
        `Cannot confirm order in ${this.status} status`
      );
    }
    this.status = OrderStatus.CONFIRMED;
  }

  cancel(reason: CancellationReason): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new InvalidOrderStateError(
        'Cannot cancel shipped order'
      );
    }
    this.status = OrderStatus.CANCELLED;
  }

  // Identity-based equality
  equals(other: Order): boolean {
    return this.id.equals(other.id);
  }
}

3.2 Value Object

Has no identifier and determines equality by attribute values. Must be immutable.

// Value Object example: Money
class Money {
  private constructor(
    readonly value: number,
    readonly currency: Currency
  ) {
    if (value < 0) {
      throw new NegativeMoneyError();
    }
  }

  static of(value: number, currency: string): Money {
    return new Money(value, Currency.of(currency));
  }

  static zero(currency: string): Money {
    return new Money(0, Currency.of(currency));
  }

  add(other: Money): Money {
    this.assertSameCurrency(other);
    return new Money(this.value + other.value, this.currency);
  }

  subtract(other: Money): Money {
    this.assertSameCurrency(other);
    const result = this.value - other.value;
    if (result < 0) {
      throw new InsufficientFundsError();
    }
    return new Money(result, this.currency);
  }

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

  // Value-based equality
  equals(other: Money): boolean {
    return (
      this.value === other.value &&
      this.currency.equals(other.currency)
    );
  }

  private assertSameCurrency(other: Money): void {
    if (!this.currency.equals(other.currency)) {
      throw new CurrencyMismatchError(
        this.currency,
        other.currency
      );
    }
  }
}

// Value Object example: Address
class Address {
  constructor(
    readonly street: string,
    readonly city: string,
    readonly state: string,
    readonly zipCode: string,
    readonly country: Country
  ) {
    this.validate();
  }

  private validate(): void {
    if (!this.street || !this.city || !this.zipCode) {
      throw new InvalidAddressError();
    }
  }

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

  withStreet(newStreet: string): Address {
    return new Address(
      newStreet,
      this.city,
      this.state,
      this.zipCode,
      this.country
    );
  }
}

Entity vs Value Object decision criteria:

CriterionEntityValue Object
IdentifierDistinguished by unique IDDistinguished by values
MutabilityState can changeImmutable (create new)
LifecycleCreate - Modify - DeleteCreate - Use - Discard
ExamplesOrder, Customer, AccountMoney, Address, DateRange

3.3 Aggregate

The consistency boundary for data changes. Aggregates follow these rules:

  1. Access only through Aggregate Root: No direct external access to internal objects
  2. Transaction boundary: Only one Aggregate modified per transaction
  3. Reference by ID only: Other Aggregates are referenced only by ID, no direct object references
  4. Eventual consistency: Between Aggregates, use Domain Events for eventual consistency
// Aggregate Root: Order
class Order {
  private readonly id: OrderId;
  private items: OrderLineItem[];
  private shippingAddress: Address;
  private status: OrderStatus;
  private readonly domainEvents: DomainEvent[] = [];

  addItem(
    productId: ProductId,
    productName: string,
    price: Money,
    quantity: Quantity
  ): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStateError(
        'Can only add items to draft orders'
      );
    }

    const existingItem = this.items.find(
      item => item.productId.equals(productId)
    );

    if (existingItem) {
      existingItem.increaseQuantity(quantity);
    } else {
      this.items.push(
        new OrderLineItem(productId, productName, price, quantity)
      );
    }

    this.domainEvents.push(
      new OrderItemAdded(this.id, productId, quantity)
    );
  }

  removeItem(productId: ProductId): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStateError(
        'Can only remove items from draft orders'
      );
    }
    this.items = this.items.filter(
      item => !item.productId.equals(productId)
    );
  }

  place(): void {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStateError(
        'Can only place draft orders'
      );
    }
    this.status = OrderStatus.PLACED;
    this.domainEvents.push(
      new OrderPlaced(this.id, this.customerId, this.totalAmount)
    );
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this.domainEvents];
    this.domainEvents.length = 0;
    return events;
  }
}

Aggregate Design Guidelines:

Good Aggregate design:              Bad Aggregate design:

+-----------------------+           +------------------------------+
| Order (Root)          |           | Order (Root)                 |
|  +-- LineItem         |           |  +-- LineItem                |
|  +-- LineItem         |           |  +-- LineItem                |
|  +-- ShippingAddr     |           |  +-- Customer (Entity!)      |
+-----------------------+           |  +-- Product (Entity!)       |
                                    |  +-- PaymentInfo             |
Small, focused Aggregate            |  +-- ShippingHistory         |
                                    +------------------------------+
                                    God Aggregate - too large

3.4 Repository

An abstraction responsible for storing and retrieving Aggregates. Exists only at the Aggregate Root level.

// Repository interface (domain layer)
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  findByCustomer(customerId: CustomerId): Promise<Order[]>;
  save(order: Order): Promise<void>;
  delete(order: Order): Promise<void>;
  nextId(): OrderId;
}

// Repository implementation (infrastructure layer)
class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly pool: Pool) {}

  async findById(id: OrderId): Promise<Order | null> {
    const result = await this.pool.query(
      `SELECT o.*, json_agg(oi.*) as items
       FROM orders o
       LEFT JOIN order_items oi ON o.id = oi.order_id
       WHERE o.id = $1
       GROUP BY o.id`,
      [id.value]
    );

    if (result.rows.length === 0) return null;
    return this.toDomain(result.rows[0]);
  }

  async save(order: Order): Promise<void> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');

      await client.query(
        `INSERT INTO orders (id, customer_id, status, placed_at)
         VALUES ($1, $2, $3, $4)
         ON CONFLICT (id) DO UPDATE SET status = $3`,
        [order.id.value, order.customerId.value,
         order.status, order.placedAt]
      );

      await client.query(
        'DELETE FROM order_items WHERE order_id = $1',
        [order.id.value]
      );

      for (const item of order.items) {
        await client.query(
          `INSERT INTO order_items
           (order_id, product_id, name, price, quantity)
           VALUES ($1, $2, $3, $4, $5)`,
          [order.id.value, item.productId.value,
           item.name, item.price.value, item.quantity.value]
        );
      }

      await client.query('COMMIT');
    } catch (e) {
      await client.query('ROLLBACK');
      throw e;
    } finally {
      client.release();
    }
  }

  nextId(): OrderId {
    return OrderId.generate();
  }
}

3.5 Domain Service

Contains domain logic that does not belong to a single Entity or Value Object.

// Domain Service: Order pricing calculation
class OrderPricingService {
  calculateTotal(
    items: OrderLineItem[],
    discountPolicy: DiscountPolicy,
    customer: CustomerGrade
  ): Money {
    const subtotal = items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.zero('KRW')
    );

    const discount = discountPolicy.calculateDiscount(
      subtotal,
      customer
    );

    return subtotal.subtract(discount);
  }
}

// Domain Service vs Application Service distinction
// Domain Service: pure domain logic
class TransferService {
  transfer(
    source: Account,
    target: Account,
    amount: Money
  ): void {
    source.withdraw(amount);
    target.deposit(amount);
  }
}

// Application Service: use case orchestration (infra dependencies)
class TransferApplicationService {
  constructor(
    private accountRepo: AccountRepository,
    private transferService: TransferService,
    private eventBus: EventBus
  ) {}

  async execute(command: TransferCommand): Promise<void> {
    const source = await this.accountRepo.findById(
      command.sourceAccountId
    );
    const target = await this.accountRepo.findById(
      command.targetAccountId
    );

    this.transferService.transfer(
      source, target, command.amount
    );

    await this.accountRepo.save(source);
    await this.accountRepo.save(target);

    await this.eventBus.publish(
      new MoneyTransferred(
        command.sourceAccountId,
        command.targetAccountId,
        command.amount
      )
    );
  }
}

3.6 Domain Event

Represents a meaningful occurrence in the domain. The key mechanism for achieving eventual consistency between Aggregates.

// Domain Event definition
abstract class DomainEvent {
  readonly occurredAt: Date;
  readonly eventId: string;

  constructor() {
    this.occurredAt = new Date();
    this.eventId = crypto.randomUUID();
  }

  abstract get eventType(): string;
}

class OrderPlaced extends DomainEvent {
  constructor(
    readonly orderId: OrderId,
    readonly customerId: CustomerId,
    readonly totalAmount: Money,
    readonly items: ReadonlyArray<OrderItemSnapshot>
  ) {
    super();
  }

  get eventType(): string {
    return 'order.placed';
  }
}

class PaymentCompleted extends DomainEvent {
  constructor(
    readonly orderId: OrderId,
    readonly paymentId: PaymentId,
    readonly amount: Money
  ) {
    super();
  }

  get eventType(): string {
    return 'payment.completed';
  }
}

// Event Handler
class OrderPlacedHandler {
  constructor(
    private inventoryService: InventoryService,
    private notificationService: NotificationService
  ) {}

  async handle(event: OrderPlaced): Promise<void> {
    // Reserve stock
    await this.inventoryService.reserveStock(
      event.items.map(item => ({
        productId: item.productId,
        quantity: item.quantity,
      }))
    );

    // Send order confirmation notification
    await this.notificationService.sendOrderConfirmation(
      event.customerId,
      event.orderId
    );
  }
}

3.7 Factory

Encapsulates complex Aggregate creation logic.

class OrderFactory {
  constructor(
    private orderRepo: OrderRepository,
    private pricingService: OrderPricingService
  ) {}

  createOrder(
    customerId: CustomerId,
    items: CreateOrderItemDTO[],
    shippingAddress: Address,
    discountCode?: string
  ): Order {
    const orderId = this.orderRepo.nextId();

    const orderItems = items.map(item =>
      new OrderLineItem(
        new ProductId(item.productId),
        item.productName,
        Money.of(item.price, 'KRW'),
        new Quantity(item.quantity)
      )
    );

    const order = new Order(
      orderId,
      customerId,
      orderItems,
      shippingAddress
    );

    if (discountCode) {
      order.applyDiscountCode(new DiscountCode(discountCode));
    }

    return order;
  }
}

4. Event Storming: Domain Discovery Workshop

Event Storming is a workshop technique created by Alberto Brandolini. Developers and domain experts explore the domain together.

4.1 Three Stages of Event Storming

Big Picture Event Storming

Purpose: Understand the big picture of the entire domain

How to run it:

  1. Attach long paper to a long wall
  2. Orange sticky notes: Domain events (written in past tense)
  3. Arrange chronologically from left to right
  4. Hot spots (red sticky notes): Mark uncertain or debated areas
  5. Pivot events: Identify major process transitions
Time flow -->

[Customer Registered]  [Product Searched]  [Added to Cart]  [Order Created]
                                                                |
                                                          [Payment Requested]
                                                                |
                                                  [Payment Approved]  OR  [Payment Failed]
                                                       |
                                                  [Order Confirmed]
                                                       |
                                                  [Shipment Started]
                                                       |
                                                  [Delivery Completed]

Process Modeling Event Storming

Purpose: Understand the detailed flow of each process

Additional elements:

  • Blue sticky notes: Command (user intent)
  • Yellow sticky notes: Actor (who performs the action)
  • Purple sticky notes: Policy (automation rule: "when X happens, do Y")
  • Green sticky notes: Read Model (information needed for decisions)
[Customer]  -->  [Place Order]  -->  [Order Aggregate]  -->  [Order Created]
 (Actor)          (Command)          (Aggregate)              (Event)
                                                                 |
                                                          [Payment Policy]
                                                             (Policy)
                                                                 |
                                                          [Request Payment]
                                                            (Command)

Software Design Event Storming

Purpose: Implementation-level design

Additional elements:

  • Identify Aggregate boundaries
  • Derive Bounded Context boundaries
  • Map Command Handlers

4.2 Event Storming Facilitation Tips

  1. Participants: Domain experts + developers (6-15 people)
  2. Space: Wall surface of 8+ meters, conducted standing
  3. Materials: 4+ colors of sticky notes, markers
  4. Duration: Big Picture 2-4 hours, Process Modeling 4-8 hours
  5. Rules: "There are no wrong answers," respect all opinions, welcome debate

Dos and Don'ts:

  • Avoid technical jargon (DB, API, microservices, etc.)
  • Prevent anyone from dominating
  • Do not expect perfect results

5. DDD Implementation in Python

Beyond TypeScript, DDD can also be implemented in Python.

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from uuid import uuid4
from datetime import datetime
from enum import Enum


# Value Object
@dataclass(frozen=True)
class Money:
    amount: int
    currency: str = "KRW"

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("Money amount cannot be negative")

    def add(self, other: Money) -> Money:
        self._assert_same_currency(other)
        return Money(self.amount + other.amount, self.currency)

    def subtract(self, other: Money) -> Money:
        self._assert_same_currency(other)
        if self.amount - other.amount < 0:
            raise ValueError("Insufficient funds")
        return Money(self.amount - other.amount, self.currency)

    def _assert_same_currency(self, other: Money) -> None:
        if self.currency != other.currency:
            raise ValueError(
                f"Currency mismatch: {self.currency} vs {other.currency}"
            )


@dataclass(frozen=True)
class OrderId:
    value: str

    @staticmethod
    def generate() -> OrderId:
        return OrderId(str(uuid4()))


@dataclass(frozen=True)
class CustomerId:
    value: str


# Entity
class OrderStatus(Enum):
    DRAFT = "DRAFT"
    PLACED = "PLACED"
    CONFIRMED = "CONFIRMED"
    SHIPPED = "SHIPPED"
    DELIVERED = "DELIVERED"
    CANCELLED = "CANCELLED"


@dataclass
class OrderLineItem:
    product_id: str
    product_name: str
    price: Money
    quantity: int

    @property
    def subtotal(self) -> Money:
        return Money(
            self.price.amount * self.quantity,
            self.price.currency
        )


# Domain Event
@dataclass(frozen=True)
class DomainEvent:
    event_id: str = field(default_factory=lambda: str(uuid4()))
    occurred_at: datetime = field(default_factory=datetime.utcnow)


@dataclass(frozen=True)
class OrderPlacedEvent(DomainEvent):
    order_id: str = ""
    customer_id: str = ""
    total_amount: int = 0


# Aggregate Root
class Order:
    def __init__(
        self,
        order_id: OrderId,
        customer_id: CustomerId,
    ) -> None:
        self._id = order_id
        self._customer_id = customer_id
        self._items: list[OrderLineItem] = []
        self._status = OrderStatus.DRAFT
        self._events: list[DomainEvent] = []
        self._created_at = datetime.utcnow()

    @property
    def id(self) -> OrderId:
        return self._id

    @property
    def status(self) -> OrderStatus:
        return self._status

    @property
    def total_amount(self) -> Money:
        if not self._items:
            return Money(0)
        total = Money(0)
        for item in self._items:
            total = total.add(item.subtotal)
        return total

    def add_item(
        self,
        product_id: str,
        product_name: str,
        price: Money,
        quantity: int,
    ) -> None:
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Can only add items to draft orders")
        if quantity <= 0:
            raise ValueError("Quantity must be positive")

        self._items.append(
            OrderLineItem(product_id, product_name, price, quantity)
        )

    def place(self) -> None:
        if not self._items:
            raise ValueError("Cannot place an empty order")
        if self._status != OrderStatus.DRAFT:
            raise ValueError("Can only place draft orders")

        self._status = OrderStatus.PLACED
        self._events.append(
            OrderPlacedEvent(
                order_id=self._id.value,
                customer_id=self._customer_id.value,
                total_amount=self.total_amount.amount,
            )
        )

    def confirm(self) -> None:
        if self._status != OrderStatus.PLACED:
            raise ValueError("Can only confirm placed orders")
        self._status = OrderStatus.CONFIRMED

    def cancel(self) -> None:
        if self._status in (OrderStatus.SHIPPED, OrderStatus.DELIVERED):
            raise ValueError("Cannot cancel shipped or delivered orders")
        self._status = OrderStatus.CANCELLED

    def collect_events(self) -> list[DomainEvent]:
        events = list(self._events)
        self._events.clear()
        return events


# Repository Interface
from abc import ABC, abstractmethod


class OrderRepository(ABC):
    @abstractmethod
    async def find_by_id(self, order_id: OrderId) -> Optional[Order]:
        ...

    @abstractmethod
    async def save(self, order: Order) -> None:
        ...

    @abstractmethod
    def next_id(self) -> OrderId:
        ...


# Application Service (Use Case)
class PlaceOrderUseCase:
    def __init__(
        self,
        order_repo: OrderRepository,
        event_publisher: EventPublisher,
    ) -> None:
        self._order_repo = order_repo
        self._event_publisher = event_publisher

    async def execute(self, command: PlaceOrderCommand) -> str:
        order_id = self._order_repo.next_id()
        order = Order(order_id, CustomerId(command.customer_id))

        for item in command.items:
            order.add_item(
                product_id=item.product_id,
                product_name=item.product_name,
                price=Money(item.price),
                quantity=item.quantity,
            )

        order.place()
        await self._order_repo.save(order)

        for event in order.collect_events():
            await self._event_publisher.publish(event)

        return order_id.value

6. DDD + CQRS + Event Sourcing

6.1 CQRS (Command Query Responsibility Segregation)

A pattern that separates commands (writes) from queries (reads). Combines naturally with DDD.

                    +--------------+
                    |    Client    |
                    +------+-------+
                           |
              +------------+------------+
              |                         |
              v                         v
       +-------------+          +-------------+
       |  Command    |          |   Query     |
       |  Handler    |          |   Handler   |
       +------+------+          +------+------+
              |                         |
              v                         v
       +-------------+          +-------------+
       |  Write      |          |  Read       |
       |  Model      |  -sync-> |  Model      |
       |  (DDD)      |          |  (DTO)      |
       +------+------+          +------+------+
              |                         |
              v                         v
       +-------------+          +-------------+
       |  Write DB   |          |  Read DB    |
       |  (Postgres) |          |  (Redis/ES) |
       +-------------+          +-------------+
// Command Side
class PlaceOrderCommandHandler {
  constructor(
    private orderRepo: OrderRepository,
    private eventBus: EventBus
  ) {}

  async handle(command: PlaceOrderCommand): Promise<OrderId> {
    const order = OrderFactory.create(
      command.customerId,
      command.items,
      command.shippingAddress
    );

    order.place();
    await this.orderRepo.save(order);

    const events = order.pullDomainEvents();
    for (const event of events) {
      await this.eventBus.publish(event);
    }

    return order.id;
  }
}

// Query Side
class OrderQueryService {
  constructor(private readDb: ReadDatabase) {}

  async getOrderSummary(
    orderId: string
  ): Promise<OrderSummaryDTO> {
    return this.readDb.query(
      'SELECT * FROM order_summaries WHERE id = $1',
      [orderId]
    );
  }

  async getCustomerOrders(
    customerId: string,
    page: number,
    size: number
  ): Promise<PaginatedResult<OrderListDTO>> {
    return this.readDb.query(
      `SELECT * FROM order_summaries
       WHERE customer_id = $1
       ORDER BY placed_at DESC
       LIMIT $2 OFFSET $3`,
      [customerId, size, (page - 1) * size]
    );
  }
}

// Projection (Event -> Read Model sync)
class OrderSummaryProjection {
  constructor(private readDb: ReadDatabase) {}

  async onOrderPlaced(event: OrderPlaced): Promise<void> {
    await this.readDb.execute(
      `INSERT INTO order_summaries
       (id, customer_id, total, status, placed_at)
       VALUES ($1, $2, $3, $4, $5)`,
      [event.orderId, event.customerId,
       event.totalAmount, 'PLACED', event.occurredAt]
    );
  }

  async onOrderConfirmed(event: OrderConfirmed): Promise<void> {
    await this.readDb.execute(
      `UPDATE order_summaries SET status = 'CONFIRMED'
       WHERE id = $1`,
      [event.orderId]
    );
  }
}

6.2 Event Sourcing

Instead of storing state, store the sequence of events.

// Event-Sourced Aggregate
class EventSourcedOrder {
  private id: OrderId;
  private status: OrderStatus;
  private items: OrderLineItem[] = [];
  private version: number = 0;
  private uncommittedEvents: DomainEvent[] = [];

  // Restore state from events
  static fromHistory(events: DomainEvent[]): EventSourcedOrder {
    const order = new EventSourcedOrder();
    for (const event of events) {
      order.apply(event, false);
    }
    return order;
  }

  place(customerId: CustomerId, items: OrderLineItem[]): void {
    this.apply(
      new OrderPlaced(OrderId.generate(), customerId, items),
      true
    );
  }

  confirm(): void {
    if (this.status !== OrderStatus.PLACED) {
      throw new InvalidOrderStateError();
    }
    this.apply(new OrderConfirmed(this.id), true);
  }

  private apply(event: DomainEvent, isNew: boolean): void {
    this.when(event);
    this.version++;
    if (isNew) {
      this.uncommittedEvents.push(event);
    }
  }

  private when(event: DomainEvent): void {
    if (event instanceof OrderPlaced) {
      this.id = event.orderId;
      this.status = OrderStatus.PLACED;
      this.items = [...event.items];
    } else if (event instanceof OrderConfirmed) {
      this.status = OrderStatus.CONFIRMED;
    } else if (event instanceof OrderCancelled) {
      this.status = OrderStatus.CANCELLED;
    }
  }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.uncommittedEvents];
  }

  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }
}

// Event Store
class EventStore {
  async save(
    aggregateId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void> {
    await this.db.query(
      `INSERT INTO event_store
       (aggregate_id, event_type, event_data, version, created_at)
       VALUES ($1, $2, $3, $4, $5)`,
      events.map((event, i) => [
        aggregateId,
        event.eventType,
        JSON.stringify(event),
        expectedVersion + i + 1,
        event.occurredAt,
      ])
    );
  }

  async load(aggregateId: string): Promise<DomainEvent[]> {
    const result = await this.db.query(
      `SELECT event_type, event_data
       FROM event_store
       WHERE aggregate_id = $1
       ORDER BY version ASC`,
      [aggregateId]
    );
    return result.rows.map(row =>
      this.deserialize(row.event_type, row.event_data)
    );
  }
}

7. DDD + Microservices

7.1 One Bounded Context = One Service

DDD's Bounded Context is the best tool for determining microservice boundaries.

+--------------------------------------------------+
|              E-commerce System                    |
|                                                   |
|  +----------+  +----------+  +----------+        |
|  | Catalog  |  |  Order   |  | Payment  |        |
|  | Service  |  | Service  |  | Service  |        |
|  |          |  |          |  |          |        |
|  | (Catalog |  | (Order   |  | (Payment |        |
|  |  BC)     |  |  BC)     |  |  BC)     |        |
|  +----+-----+  +----+-----+  +----+-----+        |
|       |              |              |              |
|       +--------------+--------------+              |
|                Event Bus                           |
|  +----------+  +----------+  +----------+        |
|  |Inventory |  | Shipping |  |Notific.  |        |
|  | Service  |  | Service  |  | Service  |        |
|  +----------+  +----------+  +----------+        |
+--------------------------------------------------+

7.2 Context Mapping = Inter-Service Communication Patterns

Context Map RelationshipMicroservice Communication Pattern
PartnershipSync API + Shared Events
Customer-SupplierAPI Gateway / REST API
ConformistUse external API as-is
ACLAPI Adapter / Translator Service
OHS/PLGraphQL / gRPC Public API
Shared KernelShared Library / Shared Schema

7.3 Event-Based Communication Between Services

// Order Service - publish events
class OrderService {
  async placeOrder(command: PlaceOrderCommand): Promise<void> {
    const order = this.orderFactory.create(command);
    order.place();

    await this.orderRepo.save(order);

    // Publish events (Outbox Pattern)
    await this.outbox.store(
      order.pullDomainEvents().map(event => ({
        aggregateId: order.id.value,
        eventType: event.eventType,
        payload: JSON.stringify(event),
      }))
    );
  }
}

// Inventory Service - subscribe to events
class InventoryEventHandler {
  @Subscribe('order.placed')
  async onOrderPlaced(event: OrderPlacedEvent): Promise<void> {
    for (const item of event.items) {
      await this.inventoryService.reserveStock(
        item.productId,
        item.quantity
      );
    }
  }

  @Subscribe('order.cancelled')
  async onOrderCancelled(
    event: OrderCancelledEvent
  ): Promise<void> {
    for (const item of event.items) {
      await this.inventoryService.releaseStock(
        item.productId,
        item.quantity
      );
    }
  }
}

8. Anti-Patterns and Refactoring

8.1 Anemic Domain Model

The most common anti-pattern. When Entities simply hold data and all logic resides in Services.

// Anti-pattern: Anemic Domain Model
class Order {
  id: string;
  status: string;
  items: OrderItem[];
  totalAmount: number;
  // Only getters/setters, no business logic
}

class OrderService {
  placeOrder(order: Order): void {
    // All business logic in the Service!
    if (order.items.length === 0) {
      throw new Error('Empty order');
    }
    if (order.status !== 'DRAFT') {
      throw new Error('Invalid status');
    }
    order.status = 'PLACED';
    order.totalAmount = order.items.reduce(
      (sum, item) => sum + item.price * item.quantity, 0
    );
  }
}

// Solution: Rich Domain Model
class Order {
  private status: OrderStatus;
  private items: OrderLineItem[];

  place(): void {
    this.assertNotEmpty();
    this.assertDraft();
    this.status = OrderStatus.PLACED;
  }

  get totalAmount(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.subtotal),
      Money.zero('KRW')
    );
  }

  private assertNotEmpty(): void {
    if (this.items.length === 0) {
      throw new EmptyOrderError();
    }
  }

  private assertDraft(): void {
    if (this.status !== OrderStatus.DRAFT) {
      throw new InvalidOrderStateError(this.status);
    }
  }
}

8.2 God Aggregate

The pattern of putting too much into a single Aggregate.

// Anti-pattern: God Aggregate
class Customer {
  id: CustomerId;
  name: string;
  email: Email;
  orders: Order[];           // All orders!
  addresses: Address[];      // All addresses!
  payments: PaymentMethod[]; // All payment methods!
  reviews: Review[];         // All reviews!
  wishlist: Product[];       // Wishlist!
  loyaltyPoints: number;
  // ...hundreds of fields
}

// Solution: Split into small Aggregates
class Customer {
  id: CustomerId;
  name: CustomerName;
  email: Email;
  grade: CustomerGrade;
}

class CustomerAddressBook {
  customerId: CustomerId;
  addresses: Address[];
}

class CustomerWallet {
  customerId: CustomerId;
  paymentMethods: PaymentMethod[];
  loyaltyPoints: LoyaltyPoints;
}

8.3 Refactoring from CRUD to DDD

A strategy for gradually transitioning existing CRUD systems to DDD:

Step 1: Establish Ubiquitous Language

Before: UserService.updateUserStatus(userId, "active")
After:  customer.activate()

Step 2: Extract Value Objects

// Before: Primitive type obsession
function createOrder(
  price: number,
  currency: string,
  street: string,
  city: string,
  zip: string
): void { /* ... */ }

// After: Introduce Value Objects
function createOrder(
  amount: Money,
  address: Address
): void { /* ... */ }

Step 3: Move domain logic into Entities

// Before: Logic in Service
class OrderService {
  canCancel(order: OrderDTO): boolean {
    return order.status !== 'SHIPPED'
      && order.status !== 'DELIVERED';
  }
}

// After: Logic in Entity
class Order {
  canCancel(): boolean {
    return !this.isShipped() && !this.isDelivered();
  }
}

Step 4: Establish Aggregate boundaries

Step 5: Introduce Domain Events

Step 6: Apply Repository pattern


9. DDD Project Structure

9.1 TypeScript Project Structure

src/
├── modules/
│   ├── order/                    # Order Bounded Context
│   │   ├── domain/
│   │   │   ├── model/
│   │   │   │   ├── Order.ts            # Aggregate Root
│   │   │   │   ├── OrderLineItem.ts    # Entity
│   │   │   │   ├── OrderId.ts          # Value Object
│   │   │   │   ├── OrderStatus.ts      # Value Object (enum)
│   │   │   │   └── Money.ts            # Value Object
│   │   │   ├── event/
│   │   │   │   ├── OrderPlaced.ts
│   │   │   │   └── OrderConfirmed.ts
│   │   │   ├── service/
│   │   │   │   └── OrderPricingService.ts
│   │   │   ├── repository/
│   │   │   │   └── OrderRepository.ts  # Interface
│   │   │   └── factory/
│   │   │       └── OrderFactory.ts
│   │   ├── application/
│   │   │   ├── command/
│   │   │   │   ├── PlaceOrderCommand.ts
│   │   │   │   └── PlaceOrderHandler.ts
│   │   │   ├── query/
│   │   │   │   ├── GetOrderQuery.ts
│   │   │   │   └── GetOrderHandler.ts
│   │   │   └── event-handler/
│   │   │       └── OrderPlacedHandler.ts
│   │   └── infrastructure/
│   │       ├── persistence/
│   │       │   └── PostgresOrderRepository.ts
│   │       ├── messaging/
│   │       │   └── KafkaOrderEventPublisher.ts
│   │       └── api/
│   │           └── OrderController.ts
│   │
│   ├── payment/                  # Payment Bounded Context
│   │   ├── domain/
│   │   ├── application/
│   │   └── infrastructure/
│   │
│   └── shared/                   # Shared Kernel
│       ├── domain/
│       │   ├── DomainEvent.ts
│       │   ├── AggregateRoot.ts
│       │   └── ValueObject.ts
│       └── infrastructure/
│           ├── EventBus.ts
│           └── UnitOfWork.ts
└── main.ts

9.2 Python Project Structure

src/
├── order/                         # Order Bounded Context
│   ├── domain/
│   │   ├── __init__.py
│   │   ├── model/
│   │   │   ├── order.py           # Aggregate Root
│   │   │   ├── order_line_item.py # Entity
│   │   │   └── value_objects.py   # Money, OrderId, etc.
   │   ├── events/
│   │   │   └── order_events.py
│   │   ├── services/
│   │   │   └── pricing_service.py
│   │   └── repositories/
│   │       └── order_repository.py  # ABC
│   ├── application/
│   │   ├── commands/
│   │   │   └── place_order.py
│   │   └── queries/
│   │       └── get_order.py
│   └── infrastructure/
│       ├── persistence/
│       │   └── sqlalchemy_order_repo.py
│       └── api/
│           └── order_routes.py
├── payment/                        # Payment Bounded Context
│   ├── domain/
│   ├── application/
│   └── infrastructure/
└── shared_kernel/
    ├── domain_event.py
    ├── aggregate_root.py
    └── value_object.py

10. Practical Checklist

10.1 DDD Adoption Checklist

  • Have Bounded Contexts been identified?
  • Does each BC have a Ubiquitous Language glossary?
  • Has the Context Map been drawn (team relationships explicit)?
  • Are Aggregate boundaries appropriate (not too large)?
  • Are Entity vs Value Object correctly distinguished?
  • Do Repositories exist only at the Aggregate Root level?
  • Do Domain Events communicate between Aggregates?
  • Is domain logic inside Entities/Value Objects (not anemic)?
  • Does ACL protect boundaries with external systems?

10.2 Aggregate Design Checklist

  • Are internal entities accessed only through the Aggregate Root?
  • Is only one Aggregate modified per transaction?
  • Are other Aggregates referenced only by ID (no object references)?
  • Is the Aggregate small enough (minimum necessary size)?
  • Are invariants always guaranteed?

11. Quiz

Let us review what we have learned.

Q1. What is the key difference between Entity and Value Object?

A1. An Entity is distinguished by its unique identifier (ID). Even if all attributes change, if the ID is the same, it is the same object. A Value Object, on the other hand, determines equality by attribute values. It has no identifier and must be immutable. For example, an "Order" is an Entity (distinguished by order number), while "Money" is a Value Object (10,000 KRW and 10,000 KRW have the same value).

Q2. What are the 4 core rules of Aggregates?

A2.

  1. Access only through Aggregate Root: No direct external access to internal objects
  2. Transaction boundary: Only one Aggregate modified per transaction
  3. Reference by ID only: Other Aggregates referenced only by ID (no direct object references)
  4. Eventual consistency: Between Aggregates, use Domain Events for asynchronous consistency
Q3. When do you use an Anti-Corruption Layer (ACL)?

A3. ACL is used when a downstream system needs to protect its own domain model. Especially when the external system's (legacy system, third-party API) model differs significantly from our domain, a translation layer is placed to prevent the external model from corrupting the internal model. Even if the external API's field names or structure change, only the ACL needs modification.

Q4. What is an Anemic Domain Model and why is it a problem?

A4. An Anemic Domain Model is a pattern where Entities have only data (getters/setters) and all business logic resides in Service classes. Martin Fowler named it an "anti-pattern." Problems: (1) Domain knowledge scattered across Services causing duplication, (2) Difficulty guaranteeing invariants, (3) Results in procedural programming rather than object-oriented design. The solution is to move business logic into Entities.

Q5. What benefits come from using CQRS and Event Sourcing with DDD?

A5. CQRS: Separates the write model (DDD Aggregates) from the read model (optimized DTOs). Writes can be optimized for domain integrity, reads for performance. Event Sourcing: Stores events instead of state, enabling (1) complete audit trails, (2) time-travel debugging, (3) event-based integration, (4) multiple views (Projections) of Aggregate state. Both are powerful for complex domains but overengineering for simple CRUD.


12. References

  1. Eric Evans - "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003)
  2. Vaughn Vernon - "Implementing Domain-Driven Design" (2013)
  3. Vaughn Vernon - "Domain-Driven Design Distilled" (2016)
  4. Alberto Brandolini - "Introducing EventStorming" (2021)
  5. Scott Millett - "Patterns, Principles, and Practices of Domain-Driven Design" (2015)
  6. Martin Fowler - "Anemic Domain Model" (blog post)
  7. Greg Young - "CQRS and Event Sourcing" (technical talk)
  8. Udi Dahan - "Clarified CQRS" (blog series)
  9. DDD Community - ddd-crew GitHub (Context Mapping templates)
  10. EventStorming.com - Alberto Brandolini's official site
  11. Microsoft - ".NET Microservices Architecture Guide" (DDD applied guide)
  12. Martin Fowler - "BoundedContext" (blog post)
  13. Chris Richardson - "Microservices Patterns" (event-driven architecture)
  14. Nick Tune - "Domain-Driven Design Starter Modelling Process" (GitHub)

현재 단락 (1/1288)

The essential complexity of software comes not from technology but from the **domain**. DDD, propose...

작성 글자: 0원문 글자: 34,968작성 단락: 0/1288