目次を表示する

DB 設計の軸 2026 ─ ドメイン駆動と特性駆動の二つの流派を行き来する 19 章

Polyglot Persistence ─ 複数 DB を持つときのドメイン側の構え

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 を実装する。

次章で 両派を行き来する 設計判断のフレームを統合する。