- Published on
DDD(ドメイン駆動設計)完全ガイド2025:戦略的/戦術的パターン、Bounded Context、イベントストーミング
- Authors

- Name
- Youngju Kim
- @fjvbn20031
目次(もくじ)
1. なぜDDDなのか:複雑(ふくざつ)なドメインへの構造的(こうぞうてき)アプローチ
ソフトウェアの本質的(ほんしつてき)な複雑性は技術ではなくドメインに起因(きいん)します。Eric Evansが2003年の著書「Domain-Driven Design: Tackling Complexity in the Heart of Software」で提案したDDDは、この複雑性に正面から向き合う設計哲学です。
1.1 従来(じゅうらい)のアプローチの限界(げんかい)
多くのプロジェクトはデータベーススキーマの設計から始めます。テーブルを作成し、CRUD APIを実装し、UIを組み立てます。シンプルなアプリケーションでは問題ありませんが、ドメインが複雑になると壁にぶつかります。
症状(しょうじょう):
- ビジネスロジックがServiceクラスのあちこちに散在
- 開発者とドメインエキスパートが異なる言語を使用
- 「注文(ちゅうもん)」という言葉が営業チーム、物流チーム、決済チームで異なる意味を持つ
- 1つの変更が予想外の場所に連鎖的に影響
- モデルが肥大化し、誰も全体を把握できない
1.2 DDDが解決(かいけつ)するもの
DDDは2つのレベルで複雑性に対処します:
| レベル | 関心事(かんしんじ) | 主要(しゅよう)ツール |
|---|---|---|
| 戦略的設計 | 全体像、チーム間の境界 | 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()
ユビキタス言語は以下のすべてで一貫(いっかん)している必要があります:
- コード(クラス名、メソッド名、変数名)
- データベーススキーマ
- APIエンドポイント
- ドキュメント
- チームの会話
// 悪い例:技術中心のネーミング
class DataProcessor {
processRecord(data: Record<string, unknown>): void {
// ...
}
}
// 良い例:ユビキタス言語を反映
class OrderFulfillmentService {
fulfillOrder(order: Order): FulfillmentResult {
// ...
}
}
2.2 Bounded Context(境界づけられたコンテキスト)
同じ用語が異なるコンテキストで異なる意味を持つことがあります。「商品(しょうひん)(Product)」という言葉を考えてみましょう:
- カタログコンテキスト: 名前、説明、画像、カテゴリ
- 在庫コンテキスト: SKU、数量、倉庫の場所
- 価格コンテキスト: 定価、割引率、プロモーションルール
- 配送コンテキスト: 重量、サイズ、配送制限
1つの巨大な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(パートナーシップ)
2つのチームが緊密に協力し、共に成功するか共に失敗します。
[注文コンテキスト] <--> [決済コンテキスト]
Partnership
Shared Kernel(共有カーネル)
2つのコンテキストが共通モデルの一部を共有します。変更には双方の合意が必要です。
[コンテキストA] -- Shared Kernel -- [コンテキストB]
(共有モジュール)
Customer-Supplier(顧客-供給者)
アップストリーム(供給者)がダウンストリーム(顧客)の要求を受け入れます。
[注文コンテキスト] --> [配送コンテキスト]
(Upstream) (Downstream)
Supplier Customer
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)を提供します。
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ダイアグラムの例
Eコマースシステムの全体Context Map:
┌──────────────┐ Partnership ┌──────────────┐
│ Identity │<──────────────────>│ Customer │
│ Context │ │ Context │
└──────┬───────┘ └──────┬───────┘
│ │
│ OHS/PL │ Customer-Supplier
│ │
▼ ▼
┌──────────────┐ Customer-Supplier ┌──────────────┐
│ Catalog │───────────────────>│ Order │
│ Context │ │ Context │
└──────────────┘ └──────┬───────┘
│
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Payment │ │ Inventory │ │ Shipping │
│ Context │ │ Context │ │ Context │
└─────┬──────┘ └────────────┘ └────────────┘
│
│ ACL
▼
┌────────────┐
│ External │
│ Payment 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
);
}
}
}
// 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の判断基準:
| 基準 | Entity | Value Object |
|---|---|---|
| 識別子 | 固有IDで区別 | 値で区別 |
| 可変性 | 状態変更可能 | 不変(新しいオブジェクト生成) |
| ライフサイクル | 生成-変更-削除 | 生成-使用-破棄 |
| 例 | 注文、顧客、口座 | 金額、住所、日付範囲 |
3.3 Aggregate(集約)
データ変更の一貫性(いっかんせい)の境界です。Aggregateは以下のルールに従います:
- Aggregate Rootを通じてのみアクセス: 外部から内部オブジェクトへの直接アクセス不可
- トランザクション境界: 1つのトランザクションで1つのAggregateのみ変更
- IDでのみ参照: 他のAggregateはIDでのみ参照、直接オブジェクト参照禁止
- 結果整合性: Aggregate間ではDomain Eventによる結果整合性
// 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)
);
}
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;
}
}
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]
);
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(ドメインサービス)
1つの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';
}
}
// 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
目的:ドメイン全体の大きな絵を把握
進め方:
- 長い壁面に長い紙を貼る
- オレンジの付箋: ドメインイベント(過去形で記述)
- 時系列で左から右に配置
- ホットスポット(赤い付箋): 不確実または議論のある部分を表示
- ピボットイベント: プロセスの主要な転換点を特定
時間の流れ →
[顧客登録済] [商品検索済] [カートに追加済] [注文作成済]
|
[決済要求済]
|
[決済承認済] 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ファシリテーションのコツ
- 参加者構成: ドメインエキスパート+開発者(6〜15名)
- 空間: 8メートル以上の壁面、立って進行
- 材料: 4色以上の付箋、マーカー
- 時間: Big Picture 2〜4時間、Process Modeling 4〜8時間
- ルール: 「正解はない」、すべての意見を尊重、議論を歓迎
禁止事項:
- 技術用語の使用(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
# Aggregate Root
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
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] = []
@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
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(コマンドクエリ責務分離)
コマンド(書き込み)とクエリ(読み取り)を分離するパターンです。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(イベント → Read Model同期)
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
状態を保存する代わりにイベントのシーケンスを保存します。
// 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;
}
}
}
// 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 1つのBounded Context = 1つのサービス
DDDのBounded Contextはマイクロサービスの境界を決定する最良のツールです。
+--------------------------------------------------+
| Eコマースシステム |
| |
| +----------+ +----------+ +----------+ |
| | 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-Supplier | API Gateway / REST API |
| Conformist | 外部APIをそのまま使用 |
| ACL | APIアダプター / Translatorサービス |
| OHS/PL | GraphQL / 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
);
}
}
// 解決策:リッチドメインモデル
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
1つの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に移動
Step 4: Aggregateの境界設定
Step 5: Domain Eventの導入
Step 6: Repositoryパターンの適用
9. 実践チェックリスト
9.1 DDD導入チェックリスト
- Bounded Contextを特定したか
- 各BCごとのユビキタス言語用語集があるか
- Context Mapを描いたか(チーム間の関係を明示)
- Aggregateの境界が適切か(大きすぎないか)
- Entity vs Value Objectを正しく区別したか
- RepositoryがAggregate Root単位であるか
- Domain EventでAggregate間の通信をしているか
- ドメインロジックがEntity/Value Object内にあるか(貧血モデルでないか)
- ACLで外部システムとの境界を保護しているか
9.2 Aggregate設計チェックリスト
- Aggregate Rootを通じてのみ内部エンティティにアクセスしているか
- 1つのトランザクションで1つのAggregateのみ変更しているか
- 他のAggregateをIDでのみ参照しているか(オブジェクト参照なし)
- Aggregateが十分に小さいか(必要最小限のサイズ)
- 不変条件(invariant)が常に保証されているか
10. クイズ
ここまでの学習内容を確認しましょう。
Q1. EntityとValue Objectの核心的な違いは何ですか?
A1. Entityは固有の識別子(ID)で区別されます。属性がすべて変わっても、同じIDであれば同じオブジェクトです。一方、Value Objectは属性の値で同等性を判断します。識別子がなく、不変である必要があります。例えば「注文(Order)」はEntity(注文番号で区別)であり、「金額(Money)」はValue Object(10,000円と10,000円は同じ価値)です。
Q2. Aggregateの4つの核心ルールは何ですか?
A2.
- Aggregate Rootを通じてのみアクセス: 外部から内部オブジェクトへの直接アクセス不可
- トランザクション境界: 1つのトランザクションで1つのAggregateのみ変更
- IDでのみ参照: 他のAggregateはIDでのみ参照(直接オブジェクト参照禁止)
- 結果整合性: 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ではオーバーエンジニアリングです。
11. 参考資料(さんこうしりょう)
- Eric Evans - "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003)
- Vaughn Vernon - "Implementing Domain-Driven Design" (2013)
- Vaughn Vernon - "Domain-Driven Design Distilled" (2016)
- Alberto Brandolini - "Introducing EventStorming" (2021)
- Scott Millett - "Patterns, Principles, and Practices of Domain-Driven Design" (2015)
- Martin Fowler - "Anemic Domain Model"(ブログ記事)
- Greg Young - "CQRS and Event Sourcing"(技術講演)
- Udi Dahan - "Clarified CQRS"(ブログシリーズ)
- DDD Community - ddd-crew GitHub(Context Mappingテンプレート)
- EventStorming.com - Alberto Brandoliniの公式サイト
- Microsoft - ".NET Microservices Architecture Guide"(DDD適用ガイド)
- Martin Fowler - "BoundedContext"(ブログ記事)
- Chris Richardson - "Microservices Patterns"(イベント駆動アーキテクチャ)
- Nick Tune - "Domain-Driven Design Starter Modelling Process"(GitHub)