ストーリー
高橋アーキテクト が新しい課題を出しました。
ミッション概要
| 項目 | 内容 |
|---|---|
| 題材 | 旅行予約システム(フライト + ホテル + レンタカー) |
| 目標 | Sagaパターンの設計と実装 |
| 所要時間 | 90分 |
| 成果物 | Saga設計図、TypeScript実装、障害シナリオ分析 |
ミッション 1: Sagaフローの設計(20分)
以下の旅行予約フローをSagaとして設計してください。
フロー:
- 予約リクエストを受け付け
- フライトを予約
- ホテルを予約
- レンタカーを予約
- 予約確定通知を送信
設計すべきこと:
- 各ステップの正常系処理と補償処理
- ChoreographyとOrchestrationどちらを選ぶか(理由付き)
- 障害が起きうるポイントと対応策
ヒントと模範回答
選択: Orchestration
理由:
- 4ステップのフローで、条件分岐もある
- フローの可視性が重要(予約の全体状態を把握)
- 1チームが予約システムを管理
Sagaステップ:
Step 1: createBooking
正常: 予約レコードを作成(PENDING)
補償: 予約をキャンセル(CANCELLED)
Step 2: reserveFlight
正常: フライトを仮予約
補償: フライト仮予約をキャンセル
Step 3: reserveHotel
正常: ホテルを仮予約
補償: ホテル仮予約をキャンセル
Step 4: reserveRentalCar
正常: レンタカーを仮予約
補償: レンタカー仮予約をキャンセル
Step 5: confirmAll
正常: 全仮予約を確定 + 通知送信
補償: なし(最後のステップのため)
障害シナリオ:
- Step 3失敗 → C2(フライトキャンセル) → C1(予約キャンセル)
- Step 4失敗 → C3 → C2 → C1
ミッション 2: Saga Orchestratorの実装(25分)
TypeScriptでSaga Orchestratorを実装してください。
ヒントと模範回答
// Saga Orchestratorの実装
interface SagaStep<TContext> {
name: string;
execute: (ctx: TContext) => Promise<Partial<TContext>>;
compensate: (ctx: TContext) => Promise<void>;
}
class SagaOrchestrator<TContext extends Record<string, unknown>> {
constructor(
private steps: SagaStep<TContext>[],
private sagaRepo: SagaRepository
) {}
async run(initialContext: TContext, sagaId: string): Promise<SagaResult<TContext>> {
let context = { ...initialContext };
const completedSteps: SagaStep<TContext>[] = [];
// Saga状態を保存
await this.sagaRepo.save({
id: sagaId,
status: "IN_PROGRESS",
currentStep: 0,
context,
});
for (let i = 0; i < this.steps.length; i++) {
const step = this.steps[i];
try {
const result = await step.execute(context);
context = { ...context, ...result };
completedSteps.push(step);
await this.sagaRepo.update(sagaId, {
currentStep: i + 1,
context,
});
} catch (error) {
console.error(`Saga ${sagaId}: Step "${step.name}" failed`, error);
await this.sagaRepo.update(sagaId, { status: "COMPENSATING" });
await this.compensate(completedSteps, context, sagaId);
return { success: false, error: (error as Error).message };
}
}
await this.sagaRepo.update(sagaId, { status: "COMPLETED" });
return { success: true, context };
}
private async compensate(
completedSteps: SagaStep<TContext>[],
context: TContext,
sagaId: string
): Promise<void> {
for (const step of [...completedSteps].reverse()) {
try {
await step.compensate(context);
} catch (error) {
console.error(`Compensation "${step.name}" failed:`, error);
await this.sagaRepo.markCompensationFailed(sagaId, step.name);
}
}
await this.sagaRepo.update(sagaId, { status: "COMPENSATED" });
}
}
ミッション 3: 旅行予約Sagaの定義(20分)
ミッション2のOrchestratorを使って、旅行予約のSagaステップを定義してください。
ヒントと模範回答
interface BookingContext {
bookingId: string;
userId: string;
flightId?: string;
hotelId?: string;
rentalCarId?: string;
tripDetails: {
destination: string;
departureDate: string;
returnDate: string;
passengers: number;
};
}
const bookingSagaSteps: SagaStep<BookingContext>[] = [
{
name: "createBooking",
execute: async (ctx) => {
const booking = await bookingService.create({
userId: ctx.userId,
status: "PENDING",
tripDetails: ctx.tripDetails,
});
return { bookingId: booking.id };
},
compensate: async (ctx) => {
await bookingService.cancel(ctx.bookingId);
},
},
{
name: "reserveFlight",
execute: async (ctx) => {
const flight = await flightService.reserve({
bookingId: ctx.bookingId,
destination: ctx.tripDetails.destination,
departureDate: ctx.tripDetails.departureDate,
returnDate: ctx.tripDetails.returnDate,
passengers: ctx.tripDetails.passengers,
});
return { flightId: flight.reservationId };
},
compensate: async (ctx) => {
if (ctx.flightId) {
await flightService.cancelReservation(ctx.flightId);
}
},
},
{
name: "reserveHotel",
execute: async (ctx) => {
const hotel = await hotelService.reserve({
bookingId: ctx.bookingId,
destination: ctx.tripDetails.destination,
checkIn: ctx.tripDetails.departureDate,
checkOut: ctx.tripDetails.returnDate,
guests: ctx.tripDetails.passengers,
});
return { hotelId: hotel.reservationId };
},
compensate: async (ctx) => {
if (ctx.hotelId) {
await hotelService.cancelReservation(ctx.hotelId);
}
},
},
{
name: "reserveRentalCar",
execute: async (ctx) => {
const car = await rentalCarService.reserve({
bookingId: ctx.bookingId,
pickupDate: ctx.tripDetails.departureDate,
returnDate: ctx.tripDetails.returnDate,
});
return { rentalCarId: car.reservationId };
},
compensate: async (ctx) => {
if (ctx.rentalCarId) {
await rentalCarService.cancelReservation(ctx.rentalCarId);
}
},
},
{
name: "confirmAndNotify",
execute: async (ctx) => {
await bookingService.confirm(ctx.bookingId);
await notificationService.send(ctx.userId, {
type: "BOOKING_CONFIRMED",
bookingId: ctx.bookingId,
});
return {};
},
compensate: async () => {
// 最後のステップのため補償不要(通知は取り消せない)
},
},
];
// Saga実行
const orchestrator = new SagaOrchestrator(bookingSagaSteps, sagaRepo);
const result = await orchestrator.run(
{ userId: "usr-123", tripDetails: { ... } } as BookingContext,
generateSagaId()
);
ミッション 4: 障害シナリオの分析(15分)
以下の障害シナリオについて、システムの挙動と最終状態を記述してください。
- ホテル予約(Step 3)がタイムアウト
- Orchestrator自身がクラッシュ
- 補償処理(フライトキャンセル)が失敗
ヒントと模範回答
シナリオ1: ホテル予約タイムアウト
1. createBooking: 成功(bookingId = "bk-001")
2. reserveFlight: 成功(flightId = "fl-001")
3. reserveHotel: タイムアウト → 例外発生
4. 補償開始:
- cancelFlight("fl-001") → 成功
- cancelBooking("bk-001") → 成功
5. 最終状態: 予約キャンセル、フライトキャンセル
注意: ホテル側で実は予約が成功していた可能性あり
→ ホテルサービス側で仮予約のTTLを設定し自動解放
シナリオ2: Orchestratorクラッシュ
1. createBooking: 成功
2. reserveFlight: 成功
3. Orchestratorクラッシュ
4. 復旧時: sagaRepoから状態を読み取り
- currentStep = 2, status = "IN_PROGRESS"
- Step 3から再開(冪等性が保証されている前提)
→ Saga状態のDB永続化が重要
シナリオ3: 補償処理の失敗
1. フローの途中で失敗
2. 補償開始
3. cancelFlight が失敗(外部API障害)
4. リトライ(指数バックオフ、最大5回)
5. それでも失敗 → 手動エスカレーション
- 運用チームにアラート
- sagaRepoにCOMPENSATION_FAILED状態を記録
- 管理画面で手動補償を実行
ミッション 5: Outboxパターンの適用(10分)
旅行予約SagaにOutboxパターンを適用し、イベント発行の信頼性を確保する設計をしてください。
ヒントと模範回答
// Outbox適用版のcreateBooking
async function createBookingWithOutbox(data: BookingData): Promise<Booking> {
return await db.transaction(async (tx) => {
// 予約レコードを作成
const booking = await tx.insert("bookings", {
id: generateId(),
userId: data.userId,
status: "PENDING",
tripDetails: data.tripDetails,
});
// Outboxにイベントを書き込み(同一トランザクション)
await tx.insert("outbox", {
aggregateType: "Booking",
aggregateId: booking.id,
eventType: "booking.created",
payload: JSON.stringify({
bookingId: booking.id,
userId: data.userId,
tripDetails: data.tripDetails,
}),
});
return booking;
});
// → Outbox Relay が非同期でイベントをKafkaに発行
}
達成チェックリスト
- Sagaフローを設計し、正常系と補償系を定義できた
- Saga Orchestratorを TypeScript で実装できた
- 旅行予約の具体的なSagaステップを定義できた
- 障害シナリオを分析し、対応策を記述できた
- Outboxパターンを適用した設計ができた
推定所要時間: 90分