Polyglot Persistence ─ 複数 DB を持つときのドメイン側の構え
ここまでの章で見てきた事実:1 つの DB で全ワークロードを賄うのは現代では困難。
- OLTP は PostgreSQL
- 分析は ClickHouse
- Cache は Redis
- イベント連携は Kafka
- 検索は Elasticsearch
これが現代の典型的なスタック。複数 DB を併用する前提でドメイン側がどう構えるか ── これが本章の主題。
Polyglot Persistence の定義
Martin Fowler “Polyglot Persistence” (2011):
Different parts of the system might use different data stores, chosen for the way that data is used.
「データの使われ方に応じて、システムの異なる部分で異なるデータストアを使う」。
Fowler はこれを Bounded Context と関連付ける。1 つの Bounded Context = 1 つの主 DB が原則で、Bounded Context が異なれば別 DB を選んでよい。
graph TB
subgraph "Bounded Context: 注文"
OLTP1[PostgreSQL: 注文 OLTP]
Stream1[Kafka: 注文イベント]
end
subgraph "Bounded Context: 在庫"
OLTP2[DynamoDB: 在庫]
Cache2[Redis: 在庫 cache]
end
subgraph "Bounded Context: 分析"
OLAP[ClickHouse: 売上分析]
end
Stream1 --> OLAP
OLTP1 -. CDC .-> OLAP
OLTP2 -. CDC .-> OLAP
Source of Truth ─ 「正」はどこか
複数 DB を持つと、同じデータが複数箇所に存在する。例:
- Postgres: 注文の現在状態
- Kafka: 注文の event 履歴
- ClickHouse: 注文の集計
- Redis: 注文の hot data
どれが正なのか?
答えは「1 つだけ正がある」
| パターン | 正 |
|---|---|
| OLTP 中心 | OLTP DB(Postgres) |
| Event 中心(Event Sourcing) | Stream / Event Log |
| 主 DB + 派生 view | 主 DB のみ |
| 混在は禁止 | ─ |
「Postgres も Kafka も両方が正」は破綻する。どちらか一方の DB を Source of Truth と決めて、他は派生(projection)として扱う。
派生は再生可能でなければならない
graph TB
S[Source of Truth<br/>Postgres or Kafka]
S --> P1[Projection 1: ClickHouse]
S --> P2[Projection 2: Elasticsearch]
S --> P3[Projection 3: Redis]
S -.再生可能.-> P1
S -.再生可能.-> P2
S -.再生可能.-> P3
style S fill:#e1f5ff
Projection が壊れたら、Source から作り直せる べき。これが Pat Helland の Outside data is identifiable, immutable, stable の現代的な実装。
同期戦略
複数 DB の同期にはいくつかのパターンがある。
パターン 1:Outbox + Stream(推奨)
第 13 章で扱った Outbox Pattern を、複数 DB 連携に拡張する:
graph LR
App[Application] --> P[(Postgres + Outbox)]
P -. Outbox Publisher.-> K[Kafka]
K --> C1[ClickHouse 同期 Consumer]
K --> C2[Elasticsearch 同期 Consumer]
K --> C3[Redis 同期 Consumer]
- Aggregate の更新と event の publish が atomic(Outbox)
- Kafka が中央の交換点
- 各 DB は独立した consumer として同期
長所: 各 DB が独立、追加・削除が簡単、再生可能(Kafka を replay) 短所: 運用が複雑、Kafka 自体の管理が必要
パターン 2:CDC(Change Data Capture)
OLTP の WAL を読んで、変更を Stream に流す。
graph LR
P[(PostgreSQL)] -. WAL .-> CDC[Debezium]
CDC --> K[Kafka]
K --> C1[ClickHouse]
K --> C2[Elasticsearch]
長所: アプリケーションコードに変更不要、漏れが起きない 短所: WAL の schema 進化に追従する必要、運用が独自
パターン 3:Application-level Dual Write(避ける)
// ❌ アンチパターン
async function placeOrder(order: Order) {
await postgres.save(order);
await kafka.publish(orderPlacedEvent(order));
await redis.set(`order:${order.id}`, order);
}
第 13 章で見たとおり、Dual Write は本質的に壊れる。クラッシュタイミングで partial failure が起きる。
Saga ─ 複数 DB の “トランザクション”
「1 つの business operation が複数 DB を更新する」場合、ACID Trans が組めない。これが Saga パターン。
Saga の概念
複数のローカル Trans を 補償操作(compensation)の連鎖 で繋げる:
sequenceDiagram
participant App as Application
participant DB1 as Order DB
participant DB2 as Inventory DB
participant DB3 as Payment DB
App->>DB1: TX1: 注文作成
DB1-->>App: OK
App->>DB2: TX2: 在庫予約
DB2-->>App: OK
App->>DB3: TX3: 支払い処理
DB3--xApp: 失敗
Note over App: 補償シーケンス開始
App->>DB2: 補償: 在庫予約取消
App->>DB1: 補償: 注文キャンセル
Choreography vs Orchestration
| 方式 | 仕組み |
|---|---|
| Choreography | 各サービスが event を発火し、他が反応する。中心なし |
| Orchestration | 中央の Saga Orchestrator が指揮 |
Choreography: 疎結合、シンプル。だが失敗時の挙動を全体で見るのが難しい Orchestration: 中央集権、可視化容易。だが orchestrator 自体が複雑になる
複雑な Saga は Orchestration、シンプルな event 連携は Choreography が現代の標準。
ドメイン側の構え
複数 DB を持つことを前提にしたとき、ドメイン層はどう設計するか。
構え 1:Aggregate は単一 DB に閉じる
Aggregate は 1 つの DB の Trans 内で完結 させる。複数 DB に跨る Aggregate は禁止。
// ✅ Aggregate は Postgres 内で閉じる
class Order {
// すべてのフィールドが Postgres の同一 Trans で扱える
}
// 別 DB との同期は event 経由
class OrderRepository {
async save(order: Order): Promise<void> {
await tx.upsert('orders', ...);
await tx.insert('outbox_events', orderPlacedEvent(order));
}
}
構え 2:Read Model は別個に設計する
ドメイン層の Aggregate と、読み出し用の Read Model は別。
// 書き込み側: Aggregate
class Order { /* ... */ }
// 読み出し側: 用途別 View
class OrderListView {
// ClickHouse から取得、集計済み
}
class OrderSearchResult {
// Elasticsearch から取得、検索用
}
これは CQRS(Command Query Responsibility Segregation) の根拠。書き込みと読み出しを分離する。
構え 3:Repository を分割する
// 書き込み(Source of Truth)
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}
// 読み出し(Projection)
interface OrderQueryService {
search(criteria: OrderSearchCriteria): Promise<OrderSearchResult[]>;
monthlyAggregate(month: Month): Promise<MonthlyAggregate>;
}
Repository は Source of Truth に対するインターフェース。Query Service は projection(複数 DB を読む可能性)。
Polyglot の罠
罠 1:「とりあえず別 DB」
「分析だから ClickHouse」「キャッシュだから Redis」と気軽に分けると、運用負担が膨らむ。本当に必要か を毎回問う。Postgres + Materialized View で済む場面も多い。
罠 2:データ整合性の隙間
A DB の更新が B DB に反映するまで数秒の遅延。その間にユーザーが画面遷移すると、自分の更新が反映されていない画面を見る。
対策:
- 更新後の即時表示は client side で
- Read your writes を必要な箇所だけ Strong consistent
- UI で「反映に少し時間がかかります」と明示する
罠 3:Schema 進化の倍数効果
A DB の schema 変更 → CDC の構成変更 → B DB の schema 変更 → Consumer 全部更新。
1 つの schema 変更が複数システムに波及する。これを抑える設計が必要:
- Avro / Protobuf + Schema Registry
- 後方互換を死守する
- Big bang ではなく段階的に
Polyglot 採用の判断軸
graph TB
Q1{単一 DB で足りるか?}
Q1 -->|Yes| Single[単一 DB を貫く]
Q1 -->|No| Q2{Bounded Context は分かれているか?}
Q2 -->|Yes| Q3{Source of Truth を 1 つに決められるか?}
Q2 -->|No| Single2[まず Bounded Context を分ける]
Q3 -->|Yes| Polyglot[Polyglot に進む]
Q3 -->|No| Single3[整理してから]
style Polyglot fill:#e1f5ff
style Single fill:#e1ffe1
style Single2 fill:#fff4e1
style Single3 fill:#fff4e1
「いきなり Polyglot にしない」が原則。シンプルから始めて、必要になったら分割する。
この章の要点
- Polyglot Persistence = データの使われ方に応じて DB を使い分ける
- Source of Truth を 1 つに決める ── 他は派生として扱う
- 同期戦略は Outbox + Stream または CDC。Dual Write は禁止
- 複数 DB の “Trans” は Saga で補償操作の連鎖として実装
- Aggregate は単一 DB に閉じる。Read Model は別個に設計(CQRS)
- 罠: とりあえず別 DB、整合性の隙間、Schema 進化の倍数効果
- いきなり Polyglot にしない。シンプルから始める
次章への問いかけ
複数 DB を持つ世界では、ドメイン駆動と特性駆動の両派が日常的に交わる。Aggregate を引きながら Partition Key を選び、Repository を書きながら projection を実装する。
次章で 両派を行き来する 設計判断のフレームを統合する。