LESSON 40分

ストーリー

佐藤CTO
サービス分解ができた。次は各サービスのデータアーキテクチャだ。フィンテックにおけるデータ設計は、通常のWebアプリケーションとは次元が異なる

佐藤CTOが強い口調で続けます。

佐藤CTO
お金を扱うデータは1円たりとも誤差が許されない。銀行勘定系の常識では、残高のズレは”バグ”ではなく”事故”だ。そしてPCI DSSは取引データの保存方法まで規定する。イベントソーシング、CQRS、暗号化 — すべてを統合したデータアーキテクチャを設計する

Database per Service

サービス別データベース選定

サービスデータベース選定理由
Payment ServiceAmazon Aurora PostgreSQLACID、トランザクション整合性、PCI DSS対応
Account ServiceAmazon Aurora PostgreSQL残高管理の強整合性、分別管理
Transfer ServiceAmazon Aurora PostgreSQL送金のACID、監査証跡
Investment ServiceAmazon Aurora PostgreSQL + DynamoDB注文/約定はAurora、ポートフォリオはDynamoDB
KYC ServiceDynamoDB書類管理はS3、審査状態はDynamoDB
Settlement ServiceAurora PostgreSQL精算バッチの集計クエリ
Notification ServiceDynamoDB高スループット、TTL自動削除
Event StoreAmazon MSK (Kafka)イベントソーシングの永続化
// データベース選定の判断マトリクス
interface DatabaseSelectionCriteria {
  service: string;
  consistency: "STRONG" | "EVENTUAL";
  queryPattern: "OLTP" | "OLAP" | "KEY_VALUE" | "TIME_SERIES";
  dataVolume: string;
  compliance: string[];
  decision: string;
  rationale: string;
}

const selections: DatabaseSelectionCriteria[] = [
  {
    service: "Payment",
    consistency: "STRONG",
    queryPattern: "OLTP",
    dataVolume: "10億件/月",
    compliance: ["PCI_DSS", "資金決済法"],
    decision: "Aurora PostgreSQL",
    rationale: "決済の原子性保証。PCI DSS Req-3のデータ保護。パーティショニングで10億件に対応",
  },
  {
    service: "Account",
    consistency: "STRONG",
    queryPattern: "OLTP",
    dataVolume: "1,000万ユーザー",
    compliance: ["資金決済法"],
    decision: "Aurora PostgreSQL",
    rationale: "残高管理の厳密なトランザクション。SELECT FOR UPDATEによる排他制御",
  },
];

イベントソーシング

決済サービスのイベントソーシング設計

金融取引では完全な監査証跡が法的義務です。イベントソーシングにより、すべての状態変更をイミュータブルなイベントとして記録します。

// 決済ドメインイベント
type PaymentEvent =
  | PaymentInitiated
  | PaymentAuthorized
  | PaymentCaptured
  | PaymentRefunded
  | PaymentCancelled
  | PaymentFailed;

interface PaymentInitiated {
  type: "PaymentInitiated";
  paymentId: string;
  merchantId: string;
  amount: Money;
  paymentMethod: PaymentMethod;
  occurredAt: Date;
  initiatedBy: string;  // 監査: 誰が開始したか
}

interface PaymentAuthorized {
  type: "PaymentAuthorized";
  paymentId: string;
  authorizationCode: string;
  cardNetworkResponse: string;
  occurredAt: Date;
}

interface PaymentCaptured {
  type: "PaymentCaptured";
  paymentId: string;
  capturedAmount: Money;
  settlementDate: Date;
  occurredAt: Date;
}

// イベントストアへの保存
class PaymentEventStore {
  async append(
    aggregateId: string,
    events: PaymentEvent[],
    expectedVersion: number,
  ): Promise<void> {
    // 楽観的ロック: expectedVersionで競合を検出
    const currentVersion = await this.getVersion(aggregateId);
    if (currentVersion !== expectedVersion) {
      throw new OptimisticLockError(aggregateId, expectedVersion, currentVersion);
    }

    // イベントを追記(イミュータブル。更新・削除は不可)
    await this.store.appendEvents(aggregateId, events, expectedVersion + 1);

    // イベントをKafkaにパブリッシュ
    await this.eventBus.publish(events);
  }

  // 集約の状態を再構築
  async loadAggregate(aggregateId: string): Promise<Payment> {
    const events = await this.store.getEvents(aggregateId);
    return Payment.fromEvents(events);
  }
}

イベントストアのデータモデル

-- イベントストアテーブル(Append Only)
CREATE TABLE payment_events (
    event_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_id    UUID NOT NULL,
    aggregate_type  VARCHAR(50) NOT NULL,
    event_type      VARCHAR(100) NOT NULL,
    event_data      JSONB NOT NULL,
    metadata        JSONB NOT NULL,  -- トレースID、ユーザーID等
    version         INTEGER NOT NULL,
    occurred_at     TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- 楽観的ロック用のユニーク制約
    UNIQUE (aggregate_id, version)
) PARTITION BY RANGE (occurred_at);

-- 月次パーティション(10億件/月に対応)
CREATE TABLE payment_events_2026_01
    PARTITION OF payment_events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

-- インデックス
CREATE INDEX idx_payment_events_aggregate
    ON payment_events (aggregate_id, version);

CQRS の適用

読み取り最適化

イベントソーシングで書き込みを最適化した一方、読み取りは別のモデル(リードモデル)で最適化します。

graph LR
    subgraph Write["Write Side - Command"]
        WR["Payment Aggregate
Event Store
Aurora PG
Append Only"] WP["強整合性
書き込み最適化
10,000 TPS"] end subgraph Read["Read Side - Query"] RD["Payment Read Model
Materialized View
ElastiCache + DynamoDB"] RP["結果整合性
読み取り最適化
50,000 RPS"] end WR -- "イベント
Projection" --> RD style Write fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e40af style Read fill:#d1fae5,stroke:#059669,color:#065f46 style WR fill:#dbeafe,stroke:#2563eb,color:#1e40af style RD fill:#d1fae5,stroke:#059669,color:#065f46 style WP fill:#f3f4f6,stroke:#9ca3af,color:#374151 style RP fill:#f3f4f6,stroke:#9ca3af,color:#374151

プロジェクション(読み取りモデル生成)

// 決済の読み取りモデルを生成するプロジェクション
class PaymentReadModelProjection {
  async handle(event: PaymentEvent): Promise<void> {
    switch (event.type) {
      case "PaymentInitiated":
        await this.readStore.create({
          paymentId: event.paymentId,
          merchantId: event.merchantId,
          amount: event.amount,
          status: "INITIATED",
          createdAt: event.occurredAt,
        });
        break;

      case "PaymentCaptured":
        await this.readStore.update(event.paymentId, {
          status: "CAPTURED",
          capturedAt: event.occurredAt,
        });
        // キャッシュも更新
        await this.cache.set(
          `payment:${event.paymentId}`,
          await this.readStore.get(event.paymentId),
          { ttl: 3600 },
        );
        break;

      case "PaymentRefunded":
        await this.readStore.update(event.paymentId, {
          status: "REFUNDED",
          refundedAt: event.occurredAt,
        });
        break;
    }
  }
}

// 残高照会用の特化読み取りモデル
class BalanceReadModelProjection {
  // 残高はRedisに保持(50,000 RPSの読み取り性能)
  async handleBalanceChange(event: BalanceChangedEvent): Promise<void> {
    await this.redis.set(
      `balance:${event.userId}`,
      JSON.stringify({
        available: event.newBalance,
        pending: event.pendingAmount,
        updatedAt: event.occurredAt,
      }),
    );
  }
}

データ暗号化戦略

暗号化レイヤー

暗号化戦略:
  転送中(In Transit):
    外部通信: TLS 1.3
    内部通信: mTLS(サービスメッシュ)
    データベース接続: SSL/TLS

  保存時(At Rest):
    データベース: Aurora暗号化(AES-256、AWS KMS)
    S3: SSE-KMS
    EBS: AES-256暗号化
    バックアップ: 暗号化された状態で保存

  アプリケーションレベル:
    カード番号: トークン化(Vaultサービス)
    個人情報(PII): フィールドレベル暗号化(AES-256-GCM)
    暗号鍵: AWS KMS + HSM(PCI DSS準拠)

トークン化サービス

// カード番号のトークン化
class TokenizationService {
  private readonly hsm: HSMClient;  // Hardware Security Module

  async tokenize(cardNumber: string): Promise<string> {
    // カード番号をHSMで暗号化し、トークンを返す
    const token = await this.hsm.encrypt(cardNumber);

    // マッピングをCDE内のVaultに保存
    await this.vault.store(token, cardNumber);

    return token;  // "tok_4242424242424242" のような形式
  }

  // CDE内でのみ使用可能
  async detokenize(token: string): Promise<string> {
    return await this.vault.retrieve(token);
  }
}

「イベントソーシング + CQRS + トークン化。この3つの組み合わせが、フィンテックのデータアーキテクチャの核心だ。完全な監査証跡、読み書きの独立スケーリング、そしてカード情報の保護を同時に実現する」 — 佐藤CTO


データのライフサイクル管理

データ種別ホットストレージウォームストレージコールドストレージ保持期間
決済イベントAurora(直近3ヶ月)S3 Glacier IR(3ヶ月-1年)S3 Glacier Deep(1-10年)10年
残高スナップショットRedis(最新)Aurora(直近1年)S3(1年以上)永久
監査ログCloudWatch(30日)S3(30日-1年)S3 Glacier(1-7年)7年
KYC書類S3(審査中)S3 IA(審査完了後)S3 Glacier(5年以上)永久
分析データRedshift(直近6ヶ月)S3 Parquet(6ヶ月以上)-5年

まとめ

ポイント内容
Database per ServiceサービスごとにDB選定。決済はAurora PG
イベントソーシング決済の全状態変更をイミュータブルに記録
CQRS書き込み(Event Store)と読み取り(Redis/DynamoDB)の分離
暗号化3層暗号化(転送中/保存時/アプリケーション)
トークン化カード番号はCDE外でトークンとして流通
ライフサイクルホット→ウォーム→コールドの段階的データ管理

チェックリスト

  • サービスごとのデータベース選定理由を理解した
  • イベントソーシングの仕組みと監査証跡の重要性を把握した
  • CQRSの読み書き分離と各モデルの役割を理解した
  • PCI DSS準拠のデータ暗号化戦略を理解した
  • データのライフサイクル管理計画を把握した

次のステップへ

次は「インフラストラクチャ設計」に進みます。マルチリージョンのAWS構成、Kubernetesクラスタトポロジー、災害復旧の設計を行いましょう。


推定読了時間: 40分