EXERCISE 90分

ストーリー

高橋アーキテクト
今月の集大成だ
高橋アーキテクト
新規プロジェクトとして”決済プラットフォーム”の設計依頼が来ている。加盟店が決済を処理するためのAPIを提供するシステムだ。ヘキサゴナル、クリーン、DDD、品質特性、フィットネス関数 — 今月学んだすべてを使って設計してくれ

演習の概要

決済プラットフォーム「PayFlow」のアーキテクチャを設計してください。

項目内容
テーマ決済プラットフォーム
成果物アーキテクチャ設計書一式
ミッション数8
推定時間90分

システム要件

機能要件:

  • 加盟店がAPIを通じて決済を処理できる
  • クレジットカード決済、銀行振込、QRコード決済に対応
  • 決済の承認、キャプチャ(確定)、返金の3つの操作をサポート
  • 決済履歴の照会と月次レポートの生成
  • Webhook通知で加盟店に決済結果を通知

非機能要件:

  • 99.99%の可用性
  • 決済APIのレスポンス: 500ms以内
  • PCI DSS準拠のセキュリティ
  • 月間100万件の決済処理
  • チーム: 10名

Mission 1: イベントストーミングを実施しよう(10分)

決済プラットフォームのドメインイベント、コマンド、アクターを洗い出してください。

解答例

ドメインイベント:

  • 加盟店が登録された
  • APIキーが発行された
  • 決済が承認された(Authorized)
  • 決済が確定された(Captured)
  • 決済が拒否された(Declined)
  • 返金が処理された(Refunded)
  • 部分返金が処理された
  • Webhookが送信された
  • Webhook送信が失敗した
  • 月次レポートが生成された
  • 加盟店の入金が実行された

コマンド → イベント:

  • 決済を承認する → 決済が承認された / 決済が拒否された
  • 決済を確定する → 決済が確定された
  • 返金を処理する → 返金が処理された
  • Webhookを送信する → Webhookが送信された

アクター:

  • 加盟店アプリ: 決済承認、確定、返金、履歴照会
  • 管理者: 加盟店管理、レポート閲覧
  • システム: Webhook送信、入金処理、レポート生成

Mission 2: Bounded Contextを特定しよう(10分)

解答例
┌─────────────────────────────────────────┐
│  PayFlow 決済プラットフォーム             │
│                                         │
│  ┌──────────┐  ┌──────────────┐         │
│  │加盟店管理BC│  │ 決済処理BC    │         │
│  │          │  │              │         │
│  │ 加盟店登録│  │ 承認/確定/返金│         │
│  │ APIキー  │  │ 決済状態管理  │         │
│  │ 料率設定 │  │              │         │
│  └──────────┘  └──────────────┘         │
│                                         │
│  ┌──────────┐  ┌──────────────┐         │
│  │通知BC    │  │ レポートBC    │         │
│  │          │  │              │         │
│  │ Webhook  │  │ 月次レポート  │         │
│  │ リトライ │  │ 売上集計     │         │
│  └──────────┘  └──────────────┘         │
│                                         │
│  ┌──────────┐                           │
│  │ 入金BC   │                           │
│  │          │                           │
│  │ 加盟店入金│                           │
│  │ 手数料計算│                           │
│  └──────────┘                           │
└─────────────────────────────────────────┘

5つのBounded Context:

  1. 加盟店管理BC: 加盟店の登録、APIキー管理、料率設定
  2. 決済処理BC: 決済の承認、確定、返金(コアドメイン)
  3. 通知BC: Webhook送信、リトライ
  4. レポートBC: 月次レポート、売上集計
  5. 入金BC: 加盟店への入金処理、手数料計算

Mission 3: コアドメインのAggregateを設計しよう(15分)

決済処理BC(コアドメイン)のAggregateを設計・実装してください。

解答例
// 決済処理BC: Payment Aggregate

// Value Objects
class PaymentId {
  private constructor(private readonly _value: string) {}
  static generate(): PaymentId {
    return new PaymentId(`pay_${crypto.randomUUID().replace(/-/g, '')}`);
  }
  static fromString(value: string): PaymentId { return new PaymentId(value); }
  get value(): string { return this._value; }
}

enum PaymentStatus {
  AUTHORIZED = 'AUTHORIZED',
  CAPTURED = 'CAPTURED',
  DECLINED = 'DECLINED',
  REFUNDED = 'REFUNDED',
  PARTIALLY_REFUNDED = 'PARTIALLY_REFUNDED',
}

class CardInfo {
  private constructor(
    readonly last4: string,
    readonly brand: string,
    readonly expiryMonth: number,
    readonly expiryYear: number
  ) {}

  static of(last4: string, brand: string, expMonth: number, expYear: number): CardInfo {
    if (!/^\d{4}$/.test(last4)) throw new Error('カード下4桁が不正');
    return new CardInfo(last4, brand, expMonth, expYear);
  }
}

// Aggregate Root
class Payment {
  private _domainEvents: DomainEvent[] = [];

  private constructor(
    private readonly _id: PaymentId,
    private readonly _merchantId: string,
    private readonly _amount: Money,
    private readonly _currency: string,
    private _status: PaymentStatus,
    private readonly _cardInfo: CardInfo,
    private _refundedAmount: Money,
    private readonly _createdAt: Date,
    private _capturedAt: Date | null
  ) {}

  static authorize(
    merchantId: string,
    amount: Money,
    cardInfo: CardInfo
  ): Payment {
    if (amount.amount <= 0) {
      throw new Error('決済金額は0より大きい必要があります');
    }

    const payment = new Payment(
      PaymentId.generate(),
      merchantId,
      amount,
      amount.currency,
      PaymentStatus.AUTHORIZED,
      cardInfo,
      Money.zero(amount.currency),
      new Date(),
      null
    );

    payment._domainEvents.push(
      new PaymentAuthorizedEvent(payment._id.value, merchantId, amount.amount)
    );
    return payment;
  }

  static decline(merchantId: string, amount: Money, reason: string): Payment {
    const payment = new Payment(
      PaymentId.generate(),
      merchantId,
      amount,
      amount.currency,
      PaymentStatus.DECLINED,
      CardInfo.of('0000', 'unknown', 0, 0),
      Money.zero(amount.currency),
      new Date(),
      null
    );
    payment._domainEvents.push(
      new PaymentDeclinedEvent(payment._id.value, reason)
    );
    return payment;
  }

  get id(): PaymentId { return this._id; }
  get status(): PaymentStatus { return this._status; }
  get amount(): Money { return this._amount; }
  get merchantId(): string { return this._merchantId; }

  capture(): void {
    if (this._status !== PaymentStatus.AUTHORIZED) {
      throw new Error('確定できるのは承認済みの決済のみです');
    }
    this._status = PaymentStatus.CAPTURED;
    this._capturedAt = new Date();
    this._domainEvents.push(
      new PaymentCapturedEvent(this._id.value, this._amount.amount)
    );
  }

  refund(amount: Money): void {
    if (this._status !== PaymentStatus.CAPTURED &&
        this._status !== PaymentStatus.PARTIALLY_REFUNDED) {
      throw new Error('返金できるのは確定済みの決済のみです');
    }

    const newRefundedTotal = this._refundedAmount.add(amount);
    if (newRefundedTotal.amount > this._amount.amount) {
      throw new Error('返金額が決済金額を超えています');
    }

    this._refundedAmount = newRefundedTotal;

    if (this._refundedAmount.equals(this._amount)) {
      this._status = PaymentStatus.REFUNDED;
    } else {
      this._status = PaymentStatus.PARTIALLY_REFUNDED;
    }

    this._domainEvents.push(
      new PaymentRefundedEvent(this._id.value, amount.amount)
    );
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents = [];
    return events;
  }
}

Mission 4: Portを定義しよう(10分)

Driving Port(Use Case)とDriven Port(外部連携)を定義してください。

解答例
// Driving Ports (Use Cases)
interface AuthorizePaymentUseCase {
  execute(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput>;
}

interface CapturePaymentUseCase {
  execute(input: CapturePaymentInput): Promise<void>;
}

interface RefundPaymentUseCase {
  execute(input: RefundPaymentInput): Promise<void>;
}

interface GetPaymentUseCase {
  execute(input: GetPaymentInput): Promise<PaymentDetailOutput>;
}

// Driven Ports
interface PaymentRepository {
  findById(id: PaymentId): Promise<Payment | null>;
  save(payment: Payment): Promise<void>;
  findByMerchantId(merchantId: string, options?: QueryOptions): Promise<Payment[]>;
}

interface CardProcessor {
  authorize(amount: Money, cardToken: string): Promise<CardProcessResult>;
  capture(externalId: string, amount: Money): Promise<CaptureResult>;
  refund(externalId: string, amount: Money): Promise<RefundResult>;
}

interface WebhookSender {
  send(merchantId: string, event: string, payload: object): Promise<void>;
}

interface DomainEventPublisher {
  publish(event: DomainEvent): Promise<void>;
  publishAll(events: DomainEvent[]): Promise<void>;
}

Mission 5: Use Caseを実装しよう(10min)

AuthorizePaymentUseCaseを実装してください。

解答例
class AuthorizePaymentUseCaseImpl implements AuthorizePaymentUseCase {
  constructor(
    private paymentRepo: PaymentRepository,
    private cardProcessor: CardProcessor,
    private eventPublisher: DomainEventPublisher
  ) {}

  async execute(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
    // 1. カードプロセッサーで承認を試みる
    const result = await this.cardProcessor.authorize(
      Money.of(input.amount, input.currency),
      input.cardToken
    );

    let payment: Payment;

    if (result.isApproved) {
      // 2a. 承認成功: Paymentを作成
      payment = Payment.authorize(
        input.merchantId,
        Money.of(input.amount, input.currency),
        CardInfo.of(result.last4, result.brand, result.expMonth, result.expYear)
      );
    } else {
      // 2b. 承認拒否: 拒否されたPaymentを記録
      payment = Payment.decline(
        input.merchantId,
        Money.of(input.amount, input.currency),
        result.declineReason
      );
    }

    // 3. 保存
    await this.paymentRepo.save(payment);

    // 4. ドメインイベントの発行
    await this.eventPublisher.publishAll(payment.pullDomainEvents());

    // 5. 結果を返す
    return {
      paymentId: payment.id.value,
      status: payment.status,
      approved: result.isApproved,
    };
  }
}

Mission 6: コンテキストマップを描こう(10分)

解答例
┌──────────┐  U/D   ┌────────────┐
│加盟店管理│───────→│ 決済処理BC  │
│  BC      │        │  (コア)     │
└──────────┘        └──┬────┬────┘
                       │PL  │PL
                  ┌────▼┐  ┌▼────────┐
                  │通知BC│  │入金BC   │
                  └─────┘  └─────────┘

                       │ PL
                  ┌────▼──────┐
                  │レポートBC  │
                  └───────────┘

┌────────────┐  ACL  ┌──────────────┐
│ 決済処理BC │──────→│ カードネットワーク │
│            │       │ (Visa/Master等) │
└────────────┘       └──────────────┘

関係パターン:
- 加盟店管理 → 決済処理: Customer-Supplier
- 決済処理 → 通知/入金/レポート: Published Language(イベント)
- 決済処理 → カードネットワーク: ACL(外部API変換層)

Mission 7: 品質特性とフィットネス関数を定義しよう(15min)

解答例

品質特性の優先順位:

  1. 可用性: 99.99%(ダウンタイム = 決済不能 = 加盟店の損失)
  2. セキュリティ: PCI DSS準拠(カード情報保護)
  3. パフォーマンス: 決済API 500ms以内
  4. 変更容易性: 新決済手段の追加が1週間以内
  5. テスト容易性: ドメインテストはDB不要

フィットネス関数:

// 1. 依存性ルール
describe('依存性ルール', () => {
  it('domain層は外部ライブラリに依存しない', () => { /* ... */ });
  it('use-cases層はadapters層に依存しない', () => { /* ... */ });
});

// 2. パフォーマンス
describe('パフォーマンス', () => {
  it('決済承認APIは500ms以内', async () => {
    const start = Date.now();
    await request(app).post('/api/payments/authorize').send(payload);
    expect(Date.now() - start).toBeLessThan(500);
  });
});

// 3. セキュリティ
describe('セキュリティ', () => {
  it('カード番号がログに出力されない', () => {
    const logFiles = getAllLogOutput();
    expect(logFiles).not.toContain(/\d{16}/); // 16桁の数字列がないこと
  });
});

// 4. モジュール境界
describe('モジュール境界', () => {
  it('各BCのindex.ts以外をimportしていない', () => { /* ... */ });
});

Mission 8: ADRを書こう(10min)

解答例
# ADR-001: PayFlowのアーキテクチャ選定

## ステータス
承認済み

## コンテキスト
月間100万件の決済を処理する決済プラットフォームを新規開発する。
99.99%の可用性とPCI DSS準拠が必須要件。チーム10名。

## 決定
モジュラーモノリス + ヘキサゴナルアーキテクチャ + DDDを採用する。

## 理由
- 決済処理BCをコアドメインとして最も手厚く設計できる
- CardProcessor PortによりカードネットワークのACLが実現できる
- テスト用のStubCardProcessorで決済ロジックの独立テストが可能
- モジュラーモノリスで運用複雑さを抑えつつ境界を維持
- 将来、通知BCやレポートBCを独立サービスに分離可能

## トレードオフ
- 得るもの: テスト容易性、変更容易性、セキュリティの分離
- 失うもの: 初期開発速度(設計に時間がかかる)、独立デプロイ不可
- 判断: 決済システムの信頼性が最優先のため、設計投資は正当化される

## 却下した選択肢
- マイクロサービス: 10名チームには運用コストが高い
- サーバーレス: PCI DSSの制約でインフラ管理が必要
- レイヤード: テスト容易性とドメインモデルの品質に問題

達成度チェック

Mission内容完了
1イベントストーミング[ ]
2Bounded Context特定[ ]
3Aggregate設計[ ]
4Port定義[ ]
5Use Case実装[ ]
6コンテキストマップ[ ]
7品質特性・フィットネス関数[ ]
8ADR作成[ ]

チェックリスト

  • イベントストーミングでドメインを発見できた
  • BCの境界を適切に切れた
  • AggregateのルールとドメインイベントをPaymentに適用できた
  • PortとAdapterの分離が明確にできた
  • Use Caseがフレームワーク非依存で実装できた
  • コンテキストマップで適切な関係パターンを選べた
  • 品質特性を優先順位付けし、フィットネス関数を定義できた
  • ADRにトレードオフを明記できた

次のステップへ

総合演習お疲れさまでした。最後に卒業クイズに挑戦しましょう。


推定所要時間: 90分