目次を表示する

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

ドメインから使われる側 ─ 共通基盤としての DB 設計

ドメインから使われる側 ─ 共通基盤としての DB 設計

ここまでの章は「特定のドメインの DB をどう設計するか」を扱ってきた。だが現実には、多数のドメインから使われる “共通基盤” としての DBを設計する場面がある。認証、API ゲートウェイ、ジョブキュー、レートリミッタ、フィーチャーフラグ、設定ストア、監査ログ、決済ゲートウェイ。

この立場の DB 設計には、ドメイン側にいるときには見えなかった独自の判断が並ぶ。ドメイン側で慣れた人ほど不慣れになりがちな領域を、本章では具体に踏み込む。

共通基盤の特殊性

graph TB
  subgraph "ドメイン側"
    D1[ドメイン A]
    D2[ドメイン B]
    D3[ドメイン C]
    D4[ドメイン D]
  end

  subgraph "共通基盤"
    P[基盤 DB<br/>認証/レート制限/<br/>フィーチャーフラグ/<br/>監査/Idempotency]
  end

  D1 --> P
  D2 --> P
  D3 --> P
  D4 --> P

  style P fill:#e1f5ff

特殊な制約:

  • 複数ドメインから使われる:API の安定性、後方互換性が極めて重要
  • トラフィックが集約される:単一ドメインより 2-10 倍のスケール要件
  • ドメインを知らない:抽象的な API を提供する
  • 可用性が極端に重要:単一障害点になる、SLO が厳しい
  • テナント分離 / マルチテナント:データの隔離、ノイジーネイバー対策
  • ガバナンス・監査:誰が何をいつ変えたか

判断 1:Idempotency キー

複数ドメインから API を叩かれる以上、network の retry が起きる。同じ API が複数回呼ばれた時、結果が一回呼ばれたのと同じになっている必要がある。

Stripe の API 流儀

Stripe Engineering Blog “Designing robust and predictable APIs with idempotency” は事実上の業界標準。

POST /v1/payment_intents HTTP/1.1
Idempotency-Key: 6a3a86b8-5e8c-4a9e-9e3d-1a2b3c4d5e6f
Content-Type: application/json

{
  "amount": 1000,
  "currency": "usd"
}

仕様:

  • POST 系のみ(GET / DELETE は HTTP 仕様で idempotent)
  • ヘッダーで client が UUID v4 を付与
  • サーバーは status code + body を保存
  • 同じキーで再 POST → 保存した結果をそのまま返す
  • パラメータが違うと error(key 流用への保護)
  • 24 時間で expire

Postgres 実装(Brandur 流)

Brandur “Implementing Stripe-like Idempotency Keys in Postgres” は Stripe ex の Brandur Leach による詳細実装記事。

CREATE TABLE idempotency_keys (
  id BIGSERIAL PRIMARY KEY,
  key TEXT NOT NULL,
  user_id BIGINT NOT NULL,
  request_method TEXT NOT NULL,
  request_path TEXT NOT NULL,
  request_params JSONB NOT NULL,
  response_code INT,
  response_body JSONB,
  recovery_point TEXT NOT NULL DEFAULT 'started',
  locked_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, key)
);

ポイント:

  • (user_id, key) で UNIQUE ── tenant 単位で key を分離
  • recovery_point で複数ステップの長時間 transaction を中断・再開可能に
  • locked_at で並行リクエストを排他

リクエストの流れ:

async function handleIdempotentRequest(req: Request): Promise<Response> {
  // 1. キーを取得(または作成)
  const record = await getOrCreateIdempotencyKey(req);

  // 2. 既に完了しているなら結果を返す
  if (record.response_code) {
    return { code: record.response_code, body: record.response_body };
  }

  // 3. 並行リクエストをロック
  if (record.locked_at && now() - record.locked_at < TIMEOUT) {
    throw new ConcurrentRequestError();
  }

  // 4. 処理を実行(recovery_point ごとに save)
  const response = await executeWithCheckpoints(req, record);

  // 5. 結果を保存
  await saveResponse(record.id, response);
  return response;
}

TTL と GC

24 時間後に自動削除:

DELETE FROM idempotency_keys WHERE created_at < now() - INTERVAL '24 hours';

これを定期 job で回す。TTL ストレージとして Redis を使う選択もある(共通基盤なら Postgres + Redis のハイブリッドが多い)。

判断 2:Schema 進化と互換性

複数ドメインから使われる DB は、スキーマを気軽に変えられない

バージョニングの方針

方針
API バージョニング/v1/auth, /v2/auth(path に version)
Header バージョニングApi-Version: 2026-05-09(Stripe 流の日付 version)
Field 追加のみで進化既存 field は変えず、新規追加
Deprecation ポリシー旧 version は最低 1 年間維持

Stripe の日付 versioning

Stripe は 日付ベース version を使う:

Stripe-Version: 2024-04-10

クライアントは作成時に “version を pin” して、サーバー側の互換性は その version の挙動を維持 する。これにより API 変更で client が壊れない。

DB の schema を変えるときも、新版を別 schema として持ち、古版へのアクセスを transformer 経由で実装 するパターンがある:

// 内部 schema は v2026_04_10 と v2026_05_09 を並走
// API リクエストの version で transformer を選ぶ
async function getUser(id: string, apiVersion: string): Promise<UserResponse> {
  const internal = await db.findUser(id);
  return apiVersion === '2026-04-10'
    ? transformV20240410(internal)
    : transformV20260509(internal);
}

判断 3:Multi-tenant 分離戦略

1 つのテナントが他のテナントに影響しない」を保証する設計。

分離戦略の比較

戦略分離度運用コストスケール
Cluster-per-tenant最強最重
Database-per-tenant
Schema-per-tenant
Row-level filtering(RLS)
Tenant prefix in PK(DynamoDB)

Postgres RLS

-- ROW LEVEL SECURITY
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- アプリ側で tenant_id を設定
SET LOCAL app.tenant_id = '...';
SELECT * FROM orders;  -- 自動的に tenant フィルタが効く

pros: SQL に WHERE tenant_id を書き忘れる事故を防げる cons: 実行計画への影響、policy のテストが面倒

DynamoDB の prefix 分離

// PK に tenant prefix
{ PK: 'TENANT#xxx#USER#123', SK: '...' }

// または Sort Key に
{ PK: 'USER#123', SK: 'TENANT#xxx' }

軽量だが、tenant 横断のクエリを書きやすくなってしまう。アプリケーション側のコード規律で守る。

判断軸

  • 企業向け SaaS で大手 tenant が混在: Database-per-tenant or Schema-per-tenant
  • 多数の小 tenant: Row-level filtering or Prefix
  • 規制要件で物理分離が必要: Database / Cluster-per-tenant
  • コスト最適: Row-level filtering(運用コストとのバランス)

判断 4:Hot Tenant / Hot Key 対策

特定の 1 tenant が全リソースを使い切る事故を防ぐ。

Token Bucket Rate Limiter

// Redis でシンプルに
async function checkLimit(tenantId: string, limit: number): Promise<boolean> {
  const key = `rl:${tenantId}:${Math.floor(Date.now() / 1000)}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 1);
  return count <= limit;
}

Bulkhead(隔壁)パターン

// tenant ごとに connection pool を分ける
const pools = new Map<string, ConnectionPool>();

function getPool(tenantId: string): ConnectionPool {
  if (!pools.has(tenantId)) {
    pools.set(tenantId, createPool({ max: 10 }));
  }
  return pools.get(tenantId)!;
}

ある tenant が pool を使い切っても、他 tenant の pool には影響しない。

Per-tenant Quota の保存

CREATE TABLE tenant_quotas (
  tenant_id UUID PRIMARY KEY,
  api_calls_per_minute INT NOT NULL,
  storage_bytes BIGINT NOT NULL,
  ...
);

CREATE TABLE tenant_usage (
  tenant_id UUID,
  date DATE,
  api_calls BIGINT,
  storage_bytes BIGINT,
  PRIMARY KEY (tenant_id, date)
);

usage を加算するときの Hot row 問題

  • 第 5 章で見た sharded counter
  • または Redis に集計、定期的に Postgres に flush

判断 5:監査・バージョニング

誰がいつ何を変えたか」を不可逆に残す要件。

Append-only Audit Log

CREATE TABLE audit_log (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  actor_id UUID NOT NULL,
  action TEXT NOT NULL,
  resource_type TEXT NOT NULL,
  resource_id TEXT NOT NULL,
  before JSONB,
  after JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT only。UPDATE / DELETE はしない。これがそのまま Pat Helland の Outside data になる ── immutable、ID 参照可能、stable。

CDC との連携

データテーブルの WAL を CDC で取り、自動的に audit log に流す方法もある(Debezium)。アプリケーション側で audit を書き忘れる事故を防げる

時間旅行クエリ

PostgreSQL の system-versioned tables(ただし extension が必要)や、Snowflake / BigQuery の Time Travel を使えば、任意時点の状態を query できる。これは強い監査要件に応える。

判断 6:クォータと Source of Truth

クォータ管理の難しさ:正確な値が高頻度で読み書きされる

Eventual Consistent な集計でいい場合

// Redis で速く加算、定期的に Postgres へ flush
async function recordUsage(tenantId: string, units: number): Promise<void> {
  await redis.hincrby(`usage:${tenantId}`, 'units', units);
}

// 5 分おきに Postgres へ
setInterval(async () => {
  const usages = await redis.hgetall('usage:*');
  await db.bulkInsertUsage(usages);
}, 5 * 60 * 1000);

正確な値ではないが、5 分以内に集計される。多くの SaaS のクォータ・課金はこれで十分。

厳密な値が必要な場合

-- Postgres の atomic UPSERT
INSERT INTO tenant_usage (tenant_id, units)
VALUES ($1, $2)
ON CONFLICT (tenant_id) DO UPDATE
  SET units = tenant_usage.units + $2;

ただし hot row 問題。次の手:

  • Sharded counter(複数行で分散、読みは SUM)
  • Stream に流して後段で集計(最終的な真実は Stream)

判断 7:Repository が DAO に堕ちる宿命

ここで ドメイン側出身のエンジニアが最も違和感を覚える論点 に触れる。

DDD の Repository

// ドメインに紐づく
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

Repository は Aggregate を返す。ドメインの言葉で書かれる。

共通基盤の “Repository”

// 共通基盤の API 例:認可
interface AuthorizationService {
  hasPermission(userId: UserId, resource: ResourceId, action: Action): Promise<boolean>;
  grant(userId: UserId, resource: ResourceId, action: Action): Promise<void>;
  revoke(userId: UserId, resource: ResourceId, action: Action): Promise<void>;
  listPermissions(userId: UserId): Promise<Permission[]>;
}

これは Aggregate を返さないPermission は単なるレコード。「Authorization のドメインの Aggregate」を共通基盤側は持っていないUserResource も別ドメインの概念)。

なぜ DAO に堕ちるのか

共通基盤は 複数ドメインから使われる 立場。特定のドメインの Aggregate は知らない。だから返せるのは:

  • テクニカルな単位のレコードPermission, IdempotencyKey, RateLimitState
  • 設定値の単位FeatureFlag, Configuration

これらは Aggregate ではない、単なる データ の塊。Repository というより DAO(Data Access Object) が近い。

でもそれが正しい

これは堕落ではなく、役割分担

  • ドメイン側は「Order」「Customer」という Aggregate を扱う
  • 共通基盤側は「IdempotencyKey」「Permission」「FeatureFlag」という テクニカル単位を扱う

両者は同じ言葉を使えない。共通基盤側に Aggregate がないのは、ドメインを知らないからであって、設計の失敗ではない。

Robert Martin の “The Database Is a Detail”ドメイン側からの視点。共通基盤側からは “DB” は 詳細ではなく、自分の本業。立場が変わると DDD の語彙が部分的にしか使えなくなる ── これがドメイン側出身者が共通基盤に出向いたときに感じる違和感の正体だ。

共通基盤の意思決定マトリクス

graph TB
  Q1[Idempotency] --> A1[Stripe 流 Header + 24h TTL]
  Q2[Schema 進化] --> A2[日付 version + transformer]
  Q3[Multi-tenant 分離] --> A3[規模 / 規制で 5 種類から選択]
  Q4[Hot tenant 対策] --> A4[Token bucket + Bulkhead + Quota]
  Q5[監査] --> A5[Append-only audit log + CDC]
  Q6[クォータ] --> A6[Eventual で十分 / 厳密ならStream集計]
  Q7[Repository] --> A7[DAO に堕ちる - それが正しい]

  style Q1 fill:#e1f5ff
  style Q2 fill:#e1f5ff
  style Q3 fill:#e1f5ff
  style Q4 fill:#e1f5ff
  style Q5 fill:#e1f5ff
  style Q6 fill:#e1f5ff
  style Q7 fill:#e1f5ff

この章の要点

  • 共通基盤は複数ドメインから使われる立場。API 安定性・後方互換性が最重要
  • Idempotency キーは Stripe 流(Header + UUIDv4 + 24h TTL + Postgres 実装)
  • Schema 進化は API version + transformer。日付 version が現代的
  • Multi-tenant 分離は規模 / 規制で 5 段階から選ぶ(Cluster / DB / Schema / RLS / Prefix)
  • Hot tenant 対策は Token bucket + Bulkhead + per-tenant Quota
  • 監査は append-only log。CDC で書き忘れを防げる
  • クォータは eventual で十分な場面が多い、厳密なら Stream 集計
  • Repository が DAO に堕ちるのは正しい。ドメインを知らない側の宿命

次章への問いかけ

共通基盤側に立つと、ドメイン側の語彙が部分的に通じない。だが現実は 複数 DB を併用する ことが多い。

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