EXERCISE 90分

ストーリー

高橋アーキテクト が新しい課題を出しました。

高橋アーキテクト
旅行予約システムのSagaを設計してほしい。フライト予約、ホテル予約、レンタカー予約を一連のSagaとして管理する。どれか1つが失敗したら、すべてを補償する必要がある

ミッション概要

項目内容
題材旅行予約システム(フライト + ホテル + レンタカー)
目標Sagaパターンの設計と実装
所要時間90分
成果物Saga設計図、TypeScript実装、障害シナリオ分析

ミッション 1: Sagaフローの設計(20分)

以下の旅行予約フローをSagaとして設計してください。

フロー:

  1. 予約リクエストを受け付け
  2. フライトを予約
  3. ホテルを予約
  4. レンタカーを予約
  5. 予約確定通知を送信

設計すべきこと:

  1. 各ステップの正常系処理と補償処理
  2. ChoreographyとOrchestrationどちらを選ぶか(理由付き)
  3. 障害が起きうるポイントと対応策
ヒントと模範回答
選択: 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分)

以下の障害シナリオについて、システムの挙動と最終状態を記述してください。

  1. ホテル予約(Step 3)がタイムアウト
  2. Orchestrator自身がクラッシュ
  3. 補償処理(フライトキャンセル)が失敗
ヒントと模範回答
シナリオ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分