ストーリー
演習の概要
決済プラットフォーム「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:
- 加盟店管理BC: 加盟店の登録、APIキー管理、料率設定
- 決済処理BC: 決済の承認、確定、返金(コアドメイン)
- 通知BC: Webhook送信、リトライ
- レポートBC: 月次レポート、売上集計
- 入金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)
解答例
品質特性の優先順位:
- 可用性: 99.99%(ダウンタイム = 決済不能 = 加盟店の損失)
- セキュリティ: PCI DSS準拠(カード情報保護)
- パフォーマンス: 決済API 500ms以内
- 変更容易性: 新決済手段の追加が1週間以内
- テスト容易性: ドメインテストは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 | イベントストーミング | [ ] |
| 2 | Bounded Context特定 | [ ] |
| 3 | Aggregate設計 | [ ] |
| 4 | Port定義 | [ ] |
| 5 | Use Case実装 | [ ] |
| 6 | コンテキストマップ | [ ] |
| 7 | 品質特性・フィットネス関数 | [ ] |
| 8 | ADR作成 | [ ] |
チェックリスト
- イベントストーミングでドメインを発見できた
- BCの境界を適切に切れた
- AggregateのルールとドメインイベントをPaymentに適用できた
- PortとAdapterの分離が明確にできた
- Use Caseがフレームワーク非依存で実装できた
- コンテキストマップで適切な関係パターンを選べた
- 品質特性を優先順位付けし、フィットネス関数を定義できた
- ADRにトレードオフを明記できた
次のステップへ
総合演習お疲れさまでした。最後に卒業クイズに挑戦しましょう。
推定所要時間: 90分