Skip to content

Split View: DDD(도메인 주도 설계) 완전 가이드 2025: 전략적/전술적 패턴, Bounded Context, 이벤트 스토밍

✨ Learn with Quiz
|

DDD(도메인 주도 설계) 완전 가이드 2025: 전략적/전술적 패턴, Bounded Context, 이벤트 스토밍

목차

1. 왜 DDD인가: 복잡한 도메인을 다루는 구조적 접근

소프트웨어의 본질적 복잡성은 기술이 아니라 도메인에서 비롯됩니다. Eric Evans가 2003년 저서 "Domain-Driven Design: Tackling Complexity in the Heart of Software"에서 제안한 DDD는 이 복잡성에 정면으로 맞서는 설계 철학입니다.

1.1 전통적 접근의 한계

많은 프로젝트가 데이터베이스 스키마부터 설계합니다. 테이블을 만들고, CRUD API를 작성하고, UI를 붙입니다. 단순한 애플리케이션에서는 문제가 없지만, 도메인이 복잡해지면 한계에 부딪힙니다.

증상들:

  • 비즈니스 로직이 Service 클래스 여기저기에 흩어져 있음
  • 개발자와 도메인 전문가가 서로 다른 언어를 사용
  • "주문"이라는 단어가 영업팀, 물류팀, 결제팀에서 다른 의미로 쓰임
  • 하나의 변경이 예상치 못한 곳에 연쇄적으로 영향을 미침
  • 모델이 점점 거대해지며 아무도 전체를 이해하지 못함

1.2 DDD가 해결하는 것

DDD는 두 가지 수준에서 복잡성을 다룹니다:

수준관심사핵심 도구
전략적 설계큰 그림, 팀 간 경계Bounded Context, Context Map, Ubiquitous Language
전술적 설계코드 수준 모델링Entity, Value Object, Aggregate, Repository, Domain Event

1.3 DDD가 적합한 프로젝트

DDD는 만능이 아닙니다. 다음 기준으로 판단하세요:

  • 적합: 복잡한 비즈니스 규칙, 여러 하위 도메인, 도메인 전문가와 긴밀한 협업 필요
  • 부적합: 단순 CRUD, 기술 중심 프로젝트(파일 변환 등), 프로토타입/MVP

2. 전략적 패턴: 큰 그림 설계

전략적 패턴은 시스템을 어떻게 나누고 팀 간 관계를 어떻게 정의하는지에 대한 것입니다.

2.1 Ubiquitous Language (유비쿼터스 언어)

DDD의 가장 근본적인 개념입니다. 개발팀과 도메인 전문가가 동일한 용어를 사용해야 합니다.

나쁜 예:

  • 도메인 전문가: "고객이 주문을 넣으면..."
  • 개발자 코드: user.createRequest()

좋은 예:

  • 도메인 전문가: "고객이 주문을 배치하면..."
  • 개발자 코드: customer.placeOrder()

유비쿼터스 언어는 다음 모든 곳에서 일관되어야 합니다:

  1. 코드 (클래스명, 메서드명, 변수명)
  2. 데이터베이스 스키마
  3. API 엔드포인트
  4. 문서
  5. 팀 대화
// 나쁜 예: 기술 중심 네이밍
class DataProcessor {
  processRecord(data: Record<string, unknown>): void {
    // ...
  }
}

// 좋은 예: 유비쿼터스 언어 반영
class OrderFulfillmentService {
  fulfillOrder(order: Order): FulfillmentResult {
    // ...
  }
}

2.2 Bounded Context (바운디드 컨텍스트)

같은 용어가 다른 맥락에서 다른 의미를 가질 수 있습니다. "상품(Product)"이라는 단어를 생각해보세요:

  • 카탈로그 컨텍스트: 이름, 설명, 이미지, 카테고리
  • 재고 컨텍스트: SKU, 수량, 창고 위치
  • 가격 컨텍스트: 정가, 할인율, 프로모션 규칙
  • 배송 컨텍스트: 무게, 크기, 배송 제한

하나의 거대한 Product 모델로 모든 것을 표현하면 모든 팀이 결합됩니다. Bounded Context는 이 문제를 해결합니다.

┌─────────────────┐  ┌─────────────────┐
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 (컨텍스트 맵)

Bounded Context 간의 관계를 정의합니다. 7가지 관계 패턴이 있습니다:

Partnership (파트너십)

두 팀이 긴밀하게 협력하며 함께 성공하거나 함께 실패합니다.

[주문 컨텍스트] ←→ [결제 컨텍스트]
         Partnership

Shared Kernel (공유 커널)

두 컨텍스트가 공통 모델의 일부를 공유합니다. 변경 시 양측 합의가 필요합니다.

[컨텍스트 A] ── Shared Kernel ── [컨텍스트 B]
                 (공유 모듈)

Customer-Supplier (고객-공급자)

업스트림(공급자)이 다운스트림(고객)의 요구를 수용합니다.

[주문 컨텍스트][배송 컨텍스트]
  (Upstream)          (Downstream)
   Supplier            Customer

Conformist (순응주의자)

다운스트림이 업스트림의 모델을 그대로 따릅니다. 협상력이 없는 경우입니다.

[외부 결제 API][우리 결제 연동]
   (Upstream)         (Downstream)
                       Conformist

Anti-Corruption Layer (부패 방지 레이어)

다운스트림이 번역 레이어를 두어 자신의 모델을 보호합니다.

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

  async processPayment(domainPayment: Payment): Promise<PaymentResult> {
    // 우리 도메인 모델 → 외부 API 모델로 변환
    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);

    // 외부 API 모델 → 우리 도메인 모델로 변환
    return new PaymentResult(
      externalResponse.status === 'OK'
        ? PaymentStatus.APPROVED
        : PaymentStatus.DECLINED,
      new TransactionId(externalResponse.txn_id)
    );
  }
}

Open Host Service (공개 호스트 서비스)

업스트림이 여러 다운스트림을 위해 잘 정의된 프로토콜(API)을 제공합니다.

               [다운스트림 A]
[OHS API] ──→  [다운스트림 B]
               [다운스트림 C]

Published Language (공개된 언어)

컨텍스트 간 교환 형식을 표준화합니다. JSON Schema, Protobuf, Avro 등이 해당합니다.

// Published Language 예시: 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 다이어그램 예시

이커머스 시스템의 전체 Context Map:

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

3. 전술적 패턴: 코드 수준 모델링

전술적 패턴은 Bounded Context 내부에서 도메인 모델을 구현하는 구체적인 방법입니다.

3.1 Entity (엔티티)

고유한 식별자로 구분되는 도메인 객체입니다. 시간이 지나도 동일한 식별자를 가지면 같은 객체입니다.

// Entity 예시: 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;
  }

  // 식별자로 동등성 판단
  equals(other: Order): boolean {
    return this.id.equals(other.id);
  }
}

3.2 Value Object (값 객체)

식별자가 없고 속성의 값으로 동등성을 판단합니다. 불변(immutable)이어야 합니다.

// Value Object 예시: 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
    );
  }

  // 값으로 동등성 판단
  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
      );
    }
  }

  toString(): string {
    return `${this.currency.symbol}${this.value.toLocaleString()}`;
  }
}

// Value Object 예시: 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 판단 기준:

기준EntityValue Object
식별자고유 ID로 구분값으로 구분
가변성상태 변경 가능불변 (새 객체 생성)
생명주기생성-변경-삭제생성-사용-폐기
예시주문, 고객, 계좌금액, 주소, 날짜범위

3.3 Aggregate (애그리거트)

데이터 변경의 일관성 경계입니다. Aggregate는 다음 규칙을 따릅니다:

  1. Aggregate Root를 통해서만 접근: 외부에서 Aggregate 내부 객체에 직접 접근 불가
  2. 트랜잭션 경계: 하나의 트랜잭션에서 하나의 Aggregate만 수정
  3. ID로만 참조: 다른 Aggregate는 ID로만 참조, 직접 객체 참조 금지
  4. 결과적 일관성: Aggregate 간에는 도메인 이벤트를 통한 결과적 일관성
// Aggregate Root: Order
class Order {
  private readonly id: OrderId;
  private items: OrderLineItem[];  // Aggregate 내부 엔티티
  private shippingAddress: Address; // Aggregate 내부 값 객체
  private status: OrderStatus;
  private readonly domainEvents: DomainEvent[] = [];

  // 외부에서 OrderLineItem에 직접 접근 불가
  // Aggregate Root의 메서드를 통해서만 조작
  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 설계 가이드라인:

좋은 Aggregate 설계:                나쁜 Aggregate 설계:

┌───────────────────┐              ┌──────────────────────────┐
Order (Root)      │              │ Order (Root)│  ├─ LineItem      │              │  ├─ LineItem│  ├─ LineItem      │              │  ├─ LineItem│  └─ ShippingAddr  │              │  ├─ Customer (Entity!)└───────────────────┘              │  ├─ Product (Entity!)                                   │  ├─ PaymentInfo작고 집중된 Aggregate                │  └─ ShippingHistory                                   └──────────────────────────┘
                                   너무 큰 God Aggregate

3.4 Repository (리포지토리)

Aggregate의 저장과 조회를 담당하는 추상화입니다. Aggregate Root 단위로만 존재합니다.

// Repository 인터페이스 (도메인 레이어)
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 구현 (인프라 레이어)
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]
      );

      // Aggregate 내부 엔티티도 함께 저장
      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();
  }

  private toDomain(row: any): Order {
    // DB 행 → 도메인 객체 변환
    return Order.reconstitute(
      new OrderId(row.id),
      new CustomerId(row.customer_id),
      OrderStatus.from(row.status),
      row.items.map((i: any) => OrderLineItem.reconstitute(
        new ProductId(i.product_id),
        i.name,
        Money.of(i.price, 'KRW'),
        new Quantity(i.quantity)
      )),
      new Date(row.placed_at)
    );
  }
}

3.5 Domain Service (도메인 서비스)

하나의 Entity나 Value Object에 속하지 않는 도메인 로직을 담습니다.

// Domain Service: 주문 가격 계산
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 구분
// Domain Service: 순수한 도메인 로직
class TransferService {
  transfer(
    source: Account,
    target: Account,
    amount: Money
  ): void {
    source.withdraw(amount);
    target.deposit(amount);
  }
}

// Application Service: 유스케이스 조율 (인프라 의존)
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 (도메인 이벤트)

도메인에서 발생한 의미 있는 사건을 표현합니다. Aggregate 간 결과적 일관성을 달성하는 핵심 메커니즘입니다.

// Domain Event 정의
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> {
    // 재고 예약
    await this.inventoryService.reserveStock(
      event.items.map(item => ({
        productId: item.productId,
        quantity: item.quantity,
      }))
    );

    // 주문 확인 알림 발송
    await this.notificationService.sendOrderConfirmation(
      event.customerId,
      event.orderId
    );
  }
}

3.7 Factory (팩토리)

복잡한 Aggregate 생성 로직을 캡슐화합니다.

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: 도메인 탐색 워크숍

Event Storming은 Alberto Brandolini가 고안한 워크숍 기법입니다. 개발자와 도메인 전문가가 함께 도메인을 탐색합니다.

4.1 Event Storming의 3단계

Big Picture Event Storming

목적: 전체 도메인의 큰 그림 파악

진행 방법:

  1. 긴 벽면에 긴 종이를 붙입니다
  2. 주황색 포스트잇: 도메인 이벤트 (과거형으로 작성)
  3. 시간순으로 왼쪽에서 오른쪽으로 배치
  4. 핫스팟 (빨간 포스트잇): 불확실하거나 논쟁이 있는 부분 표시
  5. 피봇 이벤트: 프로세스의 주요 전환점 식별
시간 흐름 →

[고객 등록됨]  [상품 검색됨]  [장바구니에 담김]  [주문 생성됨]
                                                    |
                                               [결제 요청됨]
                                                    |
                                      [결제 승인됨]  OR  [결제 실패됨]
                                           |
                                      [주문 확정됨]
                                           |
                                      [배송 시작됨]
                                           |
                                      [배송 완료됨]

Process Modeling Event Storming

목적: 각 프로세스의 상세 흐름 파악

추가 요소:

  • 파란색 포스트잇: Command (사용자 의도)
  • 노란색 포스트잇: Actor (행위자)
  • 보라색 포스트잇: Policy (자동화 규칙 "~하면 ~한다")
  • 초록색 포스트잇: Read Model (의사결정에 필요한 정보)
[고객][주문하기][Order Aggregate][주문 생성됨]
(Actor)       (Command)         (Aggregate)           (Event)
                                                         |
                                                    [결제 정책]
                                                      (Policy)
                                                         |
                                                    [결제 요청하기]
                                                     (Command)

Software Design Event Storming

목적: 구현 수준의 설계

추가 요소:

  • Aggregate 경계 식별
  • Bounded Context 경계 도출
  • Command Handler 매핑

4.2 Event Storming 퍼실리테이션 팁

  1. 참가자 구성: 도메인 전문가 + 개발자 (6~15명)
  2. 공간: 8미터 이상의 벽면, 서서 진행
  3. 재료: 4색 이상 포스트잇, 마커
  4. 시간: Big Picture 24시간, Process Modeling 48시간
  5. 규칙: "정답은 없다", 모든 의견 존중, 논쟁 환영

금지 사항:

  • 기술 용어 사용 (DB, API, 마이크로서비스 등)
  • 한 사람이 독점하는 것
  • 완벽한 결과를 기대하는 것

5. Python으로 구현하는 DDD

TypeScript 외에 Python으로도 DDD를 구현할 수 있습니다.

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)

명령(쓰기)과 조회(읽기)를 분리하는 패턴입니다. DDD와 자연스럽게 결합됩니다.

                    ┌──────────────┐
Client                    └──────┬───────┘
              ┌────────────┴────────────┐
              │                         │
              ▼                         ▼
       ┌─────────────┐          ┌─────────────┐
Command     │          │   QueryHandler     │          │   Handler       └──────┬──────┘          └──────┬──────┘
              │                         │
              ▼                         ▼
       ┌─────────────┐          ┌─────────────┐
Write       │          │  ReadModel       │  ─sync─→│  Model         (DDD)  (DTO)       └──────┬──────┘          └──────┬──────┘
              │                         │
              ▼                         ▼
       ┌─────────────┐          ┌─────────────┐
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 (이벤트 → Read Model 동기화)
class OrderSummaryProjection {
  constructor(private readDb: ReadDatabase) {}

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

6.2 Event Sourcing

상태를 저장하는 대신 이벤트의 시퀀스를 저장합니다.

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

  // 이벤트로부터 상태 복원
  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 + 마이크로서비스

7.1 하나의 Bounded Context = 하나의 서비스

DDD의 Bounded Context는 마이크로서비스 경계를 결정하는 가장 좋은 도구입니다.

┌─────────────────────────────────────────────────┐
│                 이커머스 시스템                     │
│                                                  │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
│  │ Catalog  │  │  Order   │  │ Payment  │       │
│  │ Service  │  │ Service  │  │ Service  │       │
│  │          │  │          │  │          │       │
 (Catalog (Order (Payment │       │
│  │  BC)     │  │  BC)     │  │  BC)     │       │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘       │
│       │              │              │             │
│       └──────────────┴──────────────┘             │
Event Bus│  ┌──────────┐  ┌──────────┐  ┌──────────┐       │
│  │Inventory │  │ Shipping │  │Notific.  │  │ Service  │  │ Service  │  │ Service  │       │
│  └──────────┘  └──────────┘  └──────────┘       │
└─────────────────────────────────────────────────┘

7.2 Context Mapping = 서비스 간 통신 패턴

Context Map 관계마이크로서비스 통신 패턴
Partnership동기 API + 공유 이벤트
Customer-SupplierAPI Gateway / REST API
Conformist외부 API 그대로 사용
ACLAPI Adapter / Translator 서비스
OHS/PLGraphQL / gRPC 공개 API
Shared Kernel공유 라이브러리 / 공유 스키마

7.3 서비스 간 이벤트 기반 통신

// Order Service - 이벤트 발행
class OrderService {
  async placeOrder(command: PlaceOrderCommand): Promise<void> {
    const order = this.orderFactory.create(command);
    order.place();

    await this.orderRepo.save(order);

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

// Inventory Service - 이벤트 구독
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. 안티 패턴과 리팩토링

8.1 빈혈 도메인 모델 (Anemic Domain Model)

가장 흔한 안티 패턴입니다. Entity가 단순히 데이터만 보관하고 모든 로직이 Service에 있는 경우입니다.

// 안티 패턴: 빈혈 도메인 모델
class Order {
  id: string;
  status: string;
  items: OrderItem[];
  totalAmount: number;
  // getter/setter만 존재, 비즈니스 로직 없음
}

class OrderService {
  placeOrder(order: Order): void {
    // 모든 비즈니스 로직이 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
    );
  }
}

// 해결: 풍부한 도메인 모델 (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

하나의 Aggregate에 너무 많은 것을 넣는 패턴입니다.

// 안티 패턴: God Aggregate
class Customer {
  id: CustomerId;
  name: string;
  email: Email;
  orders: Order[];           // 모든 주문!
  addresses: Address[];      // 모든 주소!
  payments: PaymentMethod[]; // 모든 결제수단!
  reviews: Review[];         // 모든 리뷰!
  wishlist: Product[];       // 위시리스트!
  loyaltyPoints: number;
  // ...수백 개의 필드
}

// 해결: 작은 Aggregate로 분리
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 CRUD에서 DDD로 리팩토링

기존 CRUD 시스템을 점진적으로 DDD로 전환하는 전략:

Step 1: 유비쿼터스 언어 수립

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

Step 2: Value Object 추출

// Before: 원시 타입 남용
function createOrder(
  price: number,
  currency: string,
  street: string,
  city: string,
  zip: string
): void { /* ... */ }

// After: Value Object 도입
function createOrder(
  amount: Money,
  address: Address
): void { /* ... */ }

Step 3: 도메인 로직을 Entity로 이동

// Before: Service에 있는 로직
class OrderService {
  canCancel(order: OrderDTO): boolean {
    return order.status !== 'SHIPPED'
      && order.status !== 'DELIVERED';
  }
}

// After: Entity에 있는 로직
class Order {
  canCancel(): boolean {
    return !this.isShipped() && !this.isDelivered();
  }
}

Step 4: Aggregate 경계 설정

Step 5: Domain Event 도입

Step 6: Repository 패턴 적용


9. DDD 프로젝트 구조

9.1 TypeScript 프로젝트 구조

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 프로젝트 구조

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. 실전 체크리스트

10.1 DDD 도입 체크리스트

  • Bounded Context를 식별했는가
  • 각 BC별 유비쿼터스 언어 용어집이 있는가
  • Context Map을 그렸는가 (팀 간 관계 명시)
  • Aggregate 경계가 적절한가 (너무 크지 않은가)
  • Entity vs Value Object를 올바르게 구분했는가
  • Repository가 Aggregate Root 단위인가
  • Domain Event로 Aggregate 간 통신하는가
  • 도메인 로직이 Entity/Value Object 안에 있는가 (빈혈 모델 아닌가)
  • ACL로 외부 시스템과의 경계를 보호하는가

10.2 Aggregate 설계 체크리스트

  • Aggregate Root를 통해서만 내부 엔티티에 접근하는가
  • 하나의 트랜잭션에서 하나의 Aggregate만 수정하는가
  • 다른 Aggregate를 ID로만 참조하는가 (객체 참조 X)
  • Aggregate가 충분히 작은가 (필요한 최소 크기)
  • 불변식(invariant)이 항상 보장되는가

11. 퀴즈

지금까지 학습한 내용을 점검해봅시다.

Q1. Entity와 Value Object의 핵심 차이점은 무엇인가요?

A1. Entity는 **고유한 식별자(ID)**로 구분됩니다. 속성이 모두 변해도 같은 ID면 같은 객체입니다. 반면 Value Object는 속성의 값으로 동등성을 판단합니다. 식별자가 없고 불변이어야 합니다. 예를 들어 "주문(Order)"은 Entity(주문번호로 구분)이고, "금액(Money)"은 Value Object(10000원과 10000원은 같은 가치)입니다.

Q2. Aggregate의 4가지 핵심 규칙은 무엇인가요?

A2.

  1. Aggregate Root를 통해서만 접근: 외부에서 내부 객체에 직접 접근 불가
  2. 트랜잭션 경계: 하나의 트랜잭션에서 하나의 Aggregate만 수정
  3. ID로만 참조: 다른 Aggregate는 ID로만 참조 (직접 객체 참조 금지)
  4. 결과적 일관성: Aggregate 간에는 Domain Event를 통한 비동기 일관성
Q3. Anti-Corruption Layer(ACL)는 어떤 상황에서 사용하나요?

A3. ACL은 다운스트림 시스템이 자신의 도메인 모델을 보호해야 할 때 사용합니다. 특히 외부 시스템(레거시 시스템, 서드파티 API)의 모델이 우리 도메인과 크게 다를 때, 번역(Translation) 레이어를 두어 외부 모델이 내부 모델을 오염시키지 않도록 합니다. 외부 API의 필드명이나 구조가 변해도 ACL만 수정하면 됩니다.

Q4. 빈혈 도메인 모델(Anemic Domain Model)이란 무엇이며, 왜 문제인가요?

A4. 빈혈 도메인 모델은 Entity가 데이터(getter/setter)만 가지고 비즈니스 로직은 모두 Service 클래스에 있는 패턴입니다. Martin Fowler가 "안티 패턴"이라 명명했습니다. 문제점: (1) 도메인 지식이 Service에 흩어져 중복 발생, (2) 불변식(invariant) 보장이 어려움, (3) 객체가 아닌 절차적 프로그래밍이 됨. 해결책은 비즈니스 로직을 Entity 안으로 이동시키는 것입니다.

Q5. CQRS와 Event Sourcing을 DDD와 함께 사용하면 어떤 이점이 있나요?

A5. CQRS: 쓰기 모델(DDD Aggregate)과 읽기 모델(최적화된 DTO)을 분리합니다. 쓰기는 도메인 무결성을, 읽기는 성능을 각각 최적화할 수 있습니다. Event Sourcing: 상태 대신 이벤트를 저장하여 (1) 완전한 감사 로그, (2) 시간여행 디버깅, (3) 이벤트 기반 통합, (4) Aggregate 상태의 다양한 뷰(Projection) 생성이 가능합니다. 둘 모두 복잡한 도메인에서 위력을 발휘하지만, 단순 CRUD에서는 오버엔지니어링입니다.


12. 참고 자료

  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" (블로그 게시물)
  7. Greg Young - "CQRS and Event Sourcing" (기술 발표 자료)
  8. Udi Dahan - "Clarified CQRS" (블로그 시리즈)
  9. DDD Community - ddd-crew GitHub (Context Mapping 템플릿)
  10. EventStorming.com - Alberto Brandolini의 공식 사이트
  11. Microsoft - ".NET Microservices Architecture Guide" (DDD 적용 가이드)
  12. Martin Fowler - "BoundedContext" (블로그 게시물)
  13. Chris Richardson - "Microservices Patterns" (이벤트 기반 아키텍처)
  14. Nick Tune - "Domain-Driven Design Starter Modelling Process" (GitHub)

DDD (Domain-Driven Design) Complete Guide 2025: Strategic/Tactical Patterns, Bounded Context, Event Storming

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)