EXERCISE 90分

ストーリー

高橋アーキテクトから4つの設計課題が出された。

「すべてデータ整合性に関する問題だ。それぞれ適切なパターンを選び、具体的な設計と実装を示してくれ。『なぜそのパターンを選んだか』の理由も重要だ」


ミッション概要

項目内容
目標4つの整合性課題に対する設計と実装
評価基準パターン選択の適切さ、実装の正確性、理由の明確さ
制限時間90分

Mission 1: 在庫管理の同時実行制御

シナリオ

ECサイトの人気商品で在庫が残り5個。同時に10人が購入ボタンを押した。

要件:

  • 在庫以上の販売を絶対に防ぐ
  • 可能な限り高いスループットを維持
  • 「在庫切れ」のレスポンスを迅速に返す

課題: 同時実行制御の設計と実装を行え。悲観的ロックと楽観的ロック、どちらが適切か判断理由とともに示せ。

ヒント

在庫は「衝突が頻発する」ケース。SELECT FOR UPDATEまたはCAS(Compare-And-Swap)を検討しよう。

回答例

選択: 悲観的ロック(SELECT FOR UPDATE)

理由: 人気商品は同時アクセスが頻発するため、楽観的ロックではリトライが多発して効率が悪い。悲観的ロックで直列化した方がスループットが安定する。

async function purchaseProduct(
  productId: number,
  quantity: number,
  userId: number
): Promise<PurchaseResult> {
  return await prisma.$transaction(async (tx) => {
    // FOR UPDATE NOWAIT: ロック取得できなければ即座にエラー
    const [product] = await tx.$queryRaw<Product[]>`
      SELECT id, stock, price
      FROM products
      WHERE id = ${productId}
      FOR UPDATE NOWAIT
    `;

    if (!product) {
      throw new Error('Product not found');
    }

    if (product.stock < quantity) {
      throw new Error('Insufficient stock');
    }

    // 在庫更新
    await tx.product.update({
      where: { id: productId },
      data: { stock: { decrement: quantity } },
    });

    // 注文作成
    const order = await tx.order.create({
      data: {
        userId,
        status: 'pending',
        total: product.price * quantity,
        items: {
          create: {
            productId,
            quantity,
            unitPrice: product.price,
          },
        },
      },
    });

    return { success: true, orderId: order.id };
  }, {
    isolationLevel: 'ReadCommitted',
    timeout: 5000,
  });
}

// コントローラー: NOWAITエラーのハンドリング
app.post('/purchase', async (req, res) => {
  try {
    const result = await purchaseProduct(req.body.productId, req.body.quantity, req.user.id);
    res.json(result);
  } catch (error) {
    if (error.message.includes('could not obtain lock')) {
      res.status(409).json({ error: 'Product is being purchased by another user. Please try again.' });
    } else if (error.message === 'Insufficient stock') {
      res.status(400).json({ error: 'Out of stock' });
    } else {
      res.status(500).json({ error: 'Internal error' });
    }
  }
});

Mission 2: ECサイトの注文フロー(Saga)

シナリオ

マイクロサービス構成のECサイト。注文処理は以下の3ステップ:

  1. 注文作成(注文サービス)
  2. 在庫確保(在庫サービス)
  3. 決済処理(決済サービス)

各サービスは独立したDBを持つ。

要件:

  • 途中で失敗した場合、すべての操作を巻き戻す
  • 各ステップの失敗原因を記録する
  • 処理状態の可視性を確保する

課題: Sagaパターン(Choreography or Orchestration)で設計せよ。補償トランザクションも含めること。

ヒント

3ステップで処理状態の可視性が求められているため、Orchestration方式が適切。各ステップの補償トランザクションを明確に定義しよう。

回答例

選択: Orchestration方式

理由: 3ステップで可視性が必要。Orchestratorで全体の状態を一元管理する。

// Saga状態管理
type SagaState = 'STARTED' | 'ORDER_CREATED' | 'INVENTORY_RESERVED'
  | 'PAYMENT_PROCESSED' | 'COMPLETED' | 'COMPENSATING' | 'FAILED';

interface SagaLog {
  sagaId: string;
  orderId?: string;
  state: SagaState;
  steps: Array<{
    service: string;
    action: string;
    status: 'success' | 'failed' | 'compensated';
    error?: string;
    timestamp: Date;
  }>;
}

class OrderSagaOrchestrator {
  async execute(orderData: OrderInput): Promise<SagaResult> {
    const sagaId = crypto.randomUUID();
    const log: SagaLog = {
      sagaId,
      state: 'STARTED',
      steps: [],
    };

    try {
      // Step 1: 注文作成
      const order = await this.orderService.create(orderData);
      log.orderId = order.id;
      log.state = 'ORDER_CREATED';
      log.steps.push({ service: 'order', action: 'create', status: 'success', timestamp: new Date() });

      // Step 2: 在庫確保
      await this.inventoryService.reserve(order.items);
      log.state = 'INVENTORY_RESERVED';
      log.steps.push({ service: 'inventory', action: 'reserve', status: 'success', timestamp: new Date() });

      // Step 3: 決済処理
      await this.paymentService.charge(order.userId, order.total);
      log.state = 'PAYMENT_PROCESSED';
      log.steps.push({ service: 'payment', action: 'charge', status: 'success', timestamp: new Date() });

      // 注文確定
      await this.orderService.confirm(order.id);
      log.state = 'COMPLETED';

      await this.saveSagaLog(log);
      return { success: true, orderId: order.id };

    } catch (error) {
      log.state = 'COMPENSATING';
      log.steps[log.steps.length] = {
        ...log.steps[log.steps.length - 1],
        status: 'failed',
        error: error.message,
      };

      // 補償トランザクション(逆順)
      await this.compensate(log);
      log.state = 'FAILED';
      await this.saveSagaLog(log);

      return { success: false, error: error.message };
    }
  }

  private async compensate(log: SagaLog): Promise<void> {
    const completedSteps = log.steps.filter(s => s.status === 'success');

    for (const step of completedSteps.reverse()) {
      try {
        switch (`${step.service}:${step.action}`) {
          case 'payment:charge':
            await this.paymentService.refund(log.orderId!);
            step.status = 'compensated';
            break;
          case 'inventory:reserve':
            await this.inventoryService.release(log.orderId!);
            step.status = 'compensated';
            break;
          case 'order:create':
            await this.orderService.cancel(log.orderId!);
            step.status = 'compensated';
            break;
        }
      } catch (compError) {
        // 補償失敗は手動対応キューに入れる
        await this.deadLetterQueue.enqueue({
          sagaId: log.sagaId,
          step,
          error: compError.message,
        });
      }
    }
  }
}

Mission 3: 銀行口座のイベントソーシング

シナリオ

銀行口座システムで、すべての取引を完全に記録する必要がある。

要件:

  • 入金、出金、送金のすべての取引を記録
  • 任意の時点の残高を再計算可能
  • 完全な監査証跡

課題: イベントソーシングで口座のデータモデルを設計せよ。

ヒント

イベントの種類(Deposited, Withdrawn, Transferred)を定義し、イベントの適用で残高を計算する集約を設計しよう。

回答例
// イベント定義
type AccountEvent =
  | { type: 'AccountOpened'; data: { ownerId: string; initialBalance: number } }
  | { type: 'MoneyDeposited'; data: { amount: number; source: string } }
  | { type: 'MoneyWithdrawn'; data: { amount: number; reason: string } }
  | { type: 'MoneyTransferredOut'; data: { amount: number; toAccountId: string; transferId: string } }
  | { type: 'MoneyTransferredIn'; data: { amount: number; fromAccountId: string; transferId: string } };

// 集約
class AccountAggregate {
  id: string;
  ownerId: string = '';
  balance: number = 0;
  status: 'active' | 'closed' = 'active';
  version: number = 0;
  private uncommittedEvents: AccountEvent[] = [];

  // コマンド: ビジネスルールの検証 + イベント生成
  deposit(amount: number, source: string): void {
    if (amount <= 0) throw new Error('Amount must be positive');
    if (this.status !== 'active') throw new Error('Account is not active');

    this.applyEvent({ type: 'MoneyDeposited', data: { amount, source } });
  }

  withdraw(amount: number, reason: string): void {
    if (amount <= 0) throw new Error('Amount must be positive');
    if (this.balance < amount) throw new Error('Insufficient funds');
    if (this.status !== 'active') throw new Error('Account is not active');

    this.applyEvent({ type: 'MoneyWithdrawn', data: { amount, reason } });
  }

  // イベント適用
  private applyEvent(event: AccountEvent): void {
    this.apply(event);
    this.uncommittedEvents.push(event);
  }

  apply(event: AccountEvent): void {
    switch (event.type) {
      case 'AccountOpened':
        this.ownerId = event.data.ownerId;
        this.balance = event.data.initialBalance;
        this.status = 'active';
        break;
      case 'MoneyDeposited':
        this.balance += event.data.amount;
        break;
      case 'MoneyWithdrawn':
        this.balance -= event.data.amount;
        break;
      case 'MoneyTransferredOut':
        this.balance -= event.data.amount;
        break;
      case 'MoneyTransferredIn':
        this.balance += event.data.amount;
        break;
    }
    this.version++;
  }

  getUncommittedEvents(): AccountEvent[] {
    return [...this.uncommittedEvents];
  }

  clearUncommittedEvents(): void {
    this.uncommittedEvents = [];
  }
}

// リポジトリ
class AccountRepository {
  constructor(private eventStore: EventStore) {}

  async load(accountId: string): Promise<AccountAggregate> {
    const events = await this.eventStore.getEvents('Account', accountId);
    const aggregate = new AccountAggregate();
    aggregate.id = accountId;
    for (const event of events) {
      aggregate.apply(event);
    }
    return aggregate;
  }

  async save(aggregate: AccountAggregate): Promise<void> {
    const events = aggregate.getUncommittedEvents();
    for (const event of events) {
      await this.eventStore.appendEvent(
        'Account',
        aggregate.id,
        event,
        aggregate.version - events.length
      );
    }
    aggregate.clearUncommittedEvents();
  }
}

// 任意時点の残高計算
async function getBalanceAtTime(accountId: string, targetTime: Date): Promise<number> {
  const events = await eventStore.getEventsBefore('Account', accountId, targetTime);
  const aggregate = new AccountAggregate();
  aggregate.id = accountId;
  for (const event of events) {
    aggregate.apply(event);
  }
  return aggregate.balance;
}

イベントストアのテーブル設計:

CREATE TABLE account_events (
  id BIGSERIAL PRIMARY KEY,
  aggregate_id VARCHAR(36) NOT NULL,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  version INT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  metadata JSONB,
  UNIQUE (aggregate_id, version)
);

CREATE INDEX idx_account_events_time
  ON account_events(aggregate_id, occurred_at);

Mission 4: Wiki の楽観的ロック

シナリオ

チームWikiで複数人が同じページを同時に編集している。

要件:

  • 同時編集の衝突を検出する
  • 後からの保存が先の変更を上書きしない
  • 衝突時にユーザーに通知し、マージを促す

課題: 楽観的ロックで同時編集の衝突検出を設計せよ。

ヒント

バージョンカラムまたはupdated_atのタイムスタンプを使い、更新時に競合を検出する設計を検討しよう。

回答例
// Wikiページのモデル
interface WikiPage {
  id: string;
  title: string;
  content: string;
  version: number;
  lastEditedBy: string;
  updatedAt: Date;
}

class WikiService {
  // ページ取得(バージョン情報を含む)
  async getPage(pageId: string): Promise<WikiPage> {
    return await prisma.wikiPage.findUnique({ where: { id: pageId } });
  }

  // ページ更新(楽観的ロック)
  async updatePage(
    pageId: string,
    content: string,
    expectedVersion: number,
    editedBy: string
  ): Promise<UpdateResult> {
    try {
      const updated = await prisma.wikiPage.updateMany({
        where: {
          id: pageId,
          version: expectedVersion, // バージョンチェック
        },
        data: {
          content,
          version: { increment: 1 },
          lastEditedBy: editedBy,
          updatedAt: new Date(),
        },
      });

      if (updated.count === 0) {
        // 競合検出
        const currentPage = await this.getPage(pageId);
        return {
          success: false,
          conflict: true,
          currentVersion: currentPage.version,
          currentContent: currentPage.content,
          lastEditedBy: currentPage.lastEditedBy,
          message: `${currentPage.lastEditedBy}さんが先に更新しました。内容を確認してください。`,
        };
      }

      return { success: true, newVersion: expectedVersion + 1 };
    } catch (error) {
      throw error;
    }
  }
}

// APIエンドポイント
app.put('/wiki/:pageId', async (req, res) => {
  const { content, version } = req.body;
  const result = await wikiService.updatePage(
    req.params.pageId,
    content,
    version,
    req.user.name
  );

  if (result.conflict) {
    res.status(409).json(result); // 409 Conflict
  } else {
    res.json(result);
  }
});

達成チェックリスト

  • Mission 1: 悲観的ロックで在庫管理の同時実行制御を設計できた
  • Mission 2: Sagaパターンで分散トランザクションを設計できた
  • Mission 3: イベントソーシングで口座管理を設計できた
  • Mission 4: 楽観的ロックで同時編集の衝突検出を設計できた

推定所要時間: 90分