🗂

【ハッカソンでClean Architecture】4人対戦ババ抜きWebSocketサーバーの設計と7つのこだわり

に公開

はじめに

2026年2月21-22日に AWS Startup Loft Tokyo で開催された Progateハッカソン powered by AWS に参加しました。学生エンジニア向けの2日間ハッカソンです。

チームで作ったのは 「ババ抜きに、革命を!」 — 4人オンライン対戦のババ抜きに、テーマルーレットとAI画像生成を組み合わせたゲームです。(ババ抜きに革命はないのがポイントです)

https://topaz.dev/projects/19fa10d5d28e056cd0cf

私の担当は WebSocketサーバー(バックエンド全般) です。本記事では、2日間でどう設計し、何にこだわったかをまとめます。

技術スタック・全体アーキテクチャ

構成

Next.js (フロントエンド)
  ↓ WebSocket
API Gateway (WebSocket API)
  ↓ 3ルート
Lambda ($connect / $disconnect / $default)

DynamoDB (シングルテーブル)

API Gateway の WebSocket API を使い、3つの Lambda ルートでハンドリングしています。$connect / $disconnect は接続管理、$default がゲームロジック本体です。

CDK によるインフラ定義

インフラは CDK (TypeScript) で定義しています。

lib/websocket-stack.ts
const table = new dynamodb.Table(this, 'GameTable', {
  partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
  sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  timeToLiveAttribute: 'ttl',
});

3つの Lambda はすべて 同一エントリポイント (lambda/index.ts) から異なるハンドラをエクスポートしています。

lib/websocket-stack.ts
const entry = path.join(__dirname, '..', 'lambda', 'index.ts');

const connectFn = new NodejsFunction(this, 'ConnectFn', {
  entry,
  handler: 'connectHandler',
  runtime: Runtime.NODEJS_22_X,
  bundling,
  environment,
});

const disconnectFn = new NodejsFunction(this, 'DisconnectFn', {
  entry,
  handler: 'disconnectHandler',
  // ...
});

const messageFn = new NodejsFunction(this, 'MessageFn', {
  entry,
  handler: 'messageHandler',
  timeout: cdk.Duration.seconds(90), // 画像生成APIの待ち時間を考慮
  // ...
});

ポイントは messageFn のタイムアウトを 90秒 に設定していること。後述する AI 画像生成 API の呼び出しを待つためです(デフォルトの3秒では間に合いません)。

こだわり1: クリーンアーキテクチャの採用

ハッカソンでクリーンアーキテクチャ?

「ハッカソンでクリーンアーキテクチャなんてオーバーエンジニアリングでは?」と思われるかもしれません。しかし、以下の理由から採用を決めました。

  1. チーム開発での並行作業 — フロントエンドとバックエンドで分業するとき、インターフェースが明確だと認識のズレが減る
  2. ゲームロジックのテスタビリティ — ババ抜きのルール(ペア除去、ターン制御、勝敗判定)は AWS に依存せずテストしたい
  3. インフラ差し替えの可能性
    4. DynamoDB を使うか、別の方法にするか決まる前にドメインロジックを書き始められる
  4. 外部のAPIサービスに依存せずに開発を進められる - 特にWSサーバーは画像生成APIやその他サービスと結合し、ハッカソンではそれらのReq, Resが頻繁に変わったり統一されていなかったり、そもそも開発順序が違うため、Infraを中心から切り離すとうまく開発が進みました

結果として、ドメインロジックのテストを高速に回せたこと、後から画像生成 API の仕様が変わっても影響範囲が限定できたことから、ハッカソンだからこそ相性が良かった と感じています。

4層構造

これは独自の構造かつ2日間(実際の工数は1日も無い)で設計から開発までやったものなので、あまり綺麗では無いかもしれませんが、DIPは大体守れています。

lambda/
├── domain/           # ドメイン層 — ビジネスルール(AWS SDK 非依存)
│   ├── model/
│   │   ├── card/         # Card, Deck
│   │   ├── game/         # Game, GameRepository(IF)
│   │   ├── player/       # Player, PublicPlayer
│   │   ├── matchmaking/  # テーマデータ, プロンプトビルダー
│   │   ├── connection/   # Connection, ConnectionRepository(IF)
│   │   ├── notification/ # NotificationService(IF)
│   │   └── imageGeneration/ # ImageGenerationService(IF)
│   └── error/
├── application/      # アプリケーション層 — ユースケース
│   ├── useCase/
│   │   ├── connectUseCase.ts
│   │   ├── disconnectUseCase.ts
│   │   ├── joinGameUseCase.ts
│   │   ├── drawCardUseCase.ts
│   │   └── getStateUseCase.ts
│   └── error/
├── infrastructure/   # インフラ層 — AWS SDK 実装
│   ├── dynamodb/         # DynamoDB リポジトリ実装
│   ├── websocket/        # API Gateway 通知実装
│   └── http/             # 画像生成 API クライアント
└── presentation/     # プレゼンテーション層 — Lambda ハンドラ
    ├── handler/
    └── dto/

Composition Root パターン(手動 DI)

lambda/index.ts がエントリポイント兼 DI コンテナです。DI フレームワークは使わず、手動でコンストラクタ注入しています。

lambda/index.ts
// --- DI Container(手動コンストラクタ注入)---
const ddb = createDynamoDBClient();

const gameRepo = new GameDynamoDBRepository(ddb);
const connectionRepo = new ConnectionDynamoDBRepository(ddb);
const matchmakingRepo = new MatchmakingDynamoDBRepository(ddb);

function createNotificationService(endpoint: string) {
  return new ApiGatewayNotificationService(endpoint, connectionRepo);
}

const imageGenerationService = new HttpImageGenerationService(
  process.env.IMAGE_API_URL!
);

const connectUseCase = new ConnectUseCase(connectionRepo);
const disconnectUseCase = new DisconnectUseCase(connectionRepo, matchmakingRepo);

// --- Lambda Handler Exports ---
export const connectHandler = createConnectHandler(connectUseCase);
export const disconnectHandler = createDisconnectHandler(disconnectUseCase);
export const messageHandler = createMessageHandler(
  (endpoint) => new JoinGameUseCase(
    connectionRepo, matchmakingRepo, gameRepo,
    createNotificationService(endpoint), imageGenerationService
  ),
  (endpoint) => new DrawCardUseCase(
    connectionRepo, gameRepo, createNotificationService(endpoint)
  ),
  (endpoint) => new GetStateUseCase(
    connectionRepo, gameRepo, createNotificationService(endpoint)
  ),
  createNotificationService
);

messageHandler にはユースケースのファクトリ関数を渡しています。これは WebSocket API の endpointPostToConnection の宛先URL)がリクエスト時にしか確定しないためです。

ドメイン層の AWS SDK 非依存

ドメイン層のリポジトリはすべてインターフェースとして定義しています。

lambda/domain/model/game/gameRepository.ts
export interface GameRepository {
  create(game: Game): Promise<void>;
  findById(gameId: string): Promise<Game | null>;
  update(game: Game): Promise<void>;
  delete(gameId: string): Promise<void>;
}
lambda/domain/model/notification/notificationService.ts
export interface NotificationService {
  sendToConnection(connectionId: string, message: ServerMessage): Promise<void>;
  broadcastToGame(game: Game, message: ServerMessage): Promise<void>;
  sendPersonalizedState(game: Game): Promise<void>;
}

この設計により、ドメインロジック(Game, Deck 等)は AWS SDK を一切インポートしていません。テスト時にはモックを注入でき、ドメインロジックの単体テストがローカルで即座に実行可能です。

こだわり2: DynamoDB シングルテーブル設計

1つの DynamoDB テーブルで、4種類のレコードを管理しています。

レコード種別 PK SK 用途
Connection CONN#{connectionId} META 接続情報・参加中のゲームID
Matchmaking MATCHMAKING CONN#{connectionId} 待機プレイヤー一覧
Roulette MATCHMAKING ROULETTE ルーレットのスロット状態
Game GAME#{gameId} META ゲーム全体の状態

設計上のポイント

TTL による自動クリーンアップ: 全レコードに ttl フィールド(2時間後のUnixタイムスタンプ)を設定しています。ハッカソン中にゴミデータが残っても自動で消えるので、手動クリーンアップ不要です。

const TWO_HOURS = 2 * 60 * 60;

function ttl(): number {
  return Math.floor(Date.now() / 1000) + TWO_HOURS;
}

PAY_PER_REQUEST: ハッカソンではアクセスパターンが予測不能なので、キャパシティプランニングを省略できるオンデマンドモードを選択。使わなければ課金されません。

こだわり3: マッチメイキングの並行制御

マッチメイキングは最もデリケートな部分です。4人が同時に join_game を送ると、複数の Lambda が同時に走り、同じプレイヤーを二重にマッチングしてしまう可能性があります。

TransactWrite による4人アトミッククレーム

マッチングが成立したとき、4人のプレイヤーをマッチメイキングキューから アトミックに削除 します。

lambda/infrastructure/dynamodb/matchmakingDynamoDBRepository.ts
async claimMatchedPlayers(connectionIds: string[]): Promise<boolean> {
  const transactItems = [
    ...connectionIds.map((id) => ({
      Delete: {
        TableName: this.ctx.tableName,
        Key: { PK: "MATCHMAKING", SK: `CONN#${id}` },
        ConditionExpression: "attribute_exists(PK)",
      },
    })),
    {
      Delete: {
        TableName: this.ctx.tableName,
        Key: { PK: "MATCHMAKING", SK: "ROULETTE" },
      },
    },
  ];

  try {
    await this.ctx.ddb.send(
      new TransactWriteCommand({ TransactItems: transactItems })
    );
    return true;
  } catch (err: any) {
    if (err.name === "TransactionCanceledException") {
      return false;
    }
    throw err;
  }
}

TransactWrite全操作が成功するか、全操作がロールバックされるか のどちらかです。各 Delete に ConditionExpression: "attribute_exists(PK)" をつけることで、別の Lambda が先にクレームしていた場合は TransactionCanceledExceptionreturn false となり、二重マッチングを防げます。

ルーレットスロットの楽観的ロック

テーマルーレットは、プレイヤーが join するたびに1スロットずつ決定されます。複数の join が同時に来た場合、同じスロットを二重に追加してしまう問題があります。

lambda/infrastructure/dynamodb/matchmakingDynamoDBRepository.ts
async saveRouletteState(
  slots: RouletteSlot[],
  expectedSlotCount?: number
): Promise<boolean> {
  const params: any = {
    TableName: this.ctx.tableName,
    Item: { /* ... */ slotCount: slots.length, ttl: ttl() },
  };

  if (expectedSlotCount !== undefined) {
    params.ConditionExpression =
      "attribute_not_exists(PK) OR slotCount = :expected";
    params.ExpressionAttributeValues = { ":expected": expectedSlotCount };
  }

  try {
    await this.ctx.ddb.send(new PutCommand(params));
    return true;
  } catch (err: any) {
    if (err.name === "ConditionalCheckFailedException") {
      return false;
    }
    throw err;
  }
}

slotCount を条件式に使い、「自分が読み取った時点のスロット数と一致する場合のみ書き込み成功」 とする楽観的ロックです。競合時はリトライループで最大5回試行します。

lambda/application/useCase/joinGameUseCase.ts
const MAX_RETRIES = 5;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
  if (slots.length >= waiting.length || slots.length >= 4) break;

  const key = THEME_SLOT_KEYS[slots.length];
  const candidate = new RouletteSlot(key, pickRandomItem(key), slots.length);
  const expectedSlotCount = slots.length;
  const updatedSlots = [...slots, candidate];

  const saved = await this.matchmakingRepo.saveRouletteState(
    updatedSlots, expectedSlotCount
  );
  if (saved) {
    newSlot = candidate;
    slots = updatedSlots;
    break;
  }

  // 競合した場合は最新状態を再取得してリトライ
  slots = await this.matchmakingRepo.getRouletteState();
}

こだわり4: ゲーム状態の楽観的ロック

ゲーム中もカードを引く操作が同時に実行される可能性があります。Game レコードに version フィールドを持たせ、更新時に前バージョンとの一致を確認しています。

lambda/infrastructure/dynamodb/gameDynamoDBRepository.ts
async update(game: Game): Promise<void> {
  const previousVersion = game.version - 1;
  await this.ctx.ddb.send(
    new PutCommand({
      TableName: this.ctx.tableName,
      Item: {
        PK: `GAME#${game.gameId}`,
        SK: "META",
        ...gameToRecord(game),
        ttl: ttl(),
      },
      ConditionExpression: "version = :v",
      ExpressionAttributeValues: { ":v": previousVersion },
    })
  );
}

分散ロック(Redis等)ではなく楽観的ロックを選んだ理由:

  • ターン制ゲームなので、実際に競合が起きるケースは稀(正常系では1人しかカードを引けない)
  • DynamoDB の ConditionExpression だけで実現でき、追加インフラが不要
  • ハッカソンの2日間で Redis を立てる余裕がない

こだわり5: テーマルーレット → AI画像生成プロンプト変換

ゲームの「革命」要素として、マッチング中にテーマルーレットが回り、その結果に応じた背景画像をAIで生成します。

「誰が・いつ・どこで・何を」の4スロット設計

lambda/domain/model/matchmaking/themeData.ts
export const THEME_SLOT_KEYS = ["who", "when", "where", "what"] as const;

export const THEME_ITEMS: Record<ThemeSlotKey, string[]> = {
  who:   ["Aさんが", "Bさんが", "Cさんが", "みんなで", "あなたが"],
  when:  ["社会人のとき", "子供のとき", "夏休みに", "深夜に", "朝一で"],
  where: ["家で", "学校で", "海辺で", "宇宙で", "森の中で"],
  what:  ["さみしかった話", "笑った話", "驚いた話", "冒険した話", "食べた話"],
};

4人がそれぞれ join するたびに1スロットずつ決定され、フロントエンドではルーレット演出が表示されます。

日本語テーマ → 英語プロンプト変換 + ランダムフレーバー

ルーレット結果をそのまま画像生成 API に渡すのではなく、英語プロンプトに変換したうえで、ランダムなフレーバーを加えています。

lambda/domain/model/matchmaking/promptBuilder.ts
const STYLE_FLAVORS = [
  "Japanese anime style",
  "clean anime line art",
  "soft cel shading",
  // ...
];

const LIGHTING_FLAVORS = [
  "soft indoor light",
  "daytime ambient light",
  // ...
];

export function buildPrompt(slots: RouletteSlot[]): string {
  const byKey = new Map(slots.map((slot) => [slot.key, slot.value]));

  const who = WHO_EN[byKey.get("who") ?? ""] ?? "Someone";
  const when = WHEN_EN[byKey.get("when") ?? ""] ?? "at some point in time";
  const where = WHERE_EN[byKey.get("where") ?? ""] ?? "in an unknown place";
  const what = WHAT_EN[byKey.get("what") ?? ""] ?? "mysterious";

  const fixedScene =
    "Japanese anime style illustration, Old Maid card game scene, " +
    "four cheerful characters sitting around a table, " +
    "holding playing cards, bright and colorful";

  const visibleLayer = `${who}, ${when}, ${where}, mood ${what}`;
  const hiddenLayer = `${styleFlavor}, ${lightingFlavor}, ...`;

  return trimToLimit(
    `${fixedScene}, ${visibleLayer}, ${hiddenLayer}, ...`,
    320
  );
}

プロンプトは以下の3層構造になっています。

内容
固定シーン ゲームの世界観を担保 Old Maid card game scene, four cheerful characters...
可視レイヤー ルーレット結果を反映 all players, as a child, by the seaside, mood playful
隠しレイヤー 毎回変化するランダムフレーバー soft cel shading, warm side light, eye-level angle

この設計により、同じルーレット結果でも毎回少し違った雰囲気の画像が生成されます。プロンプトは最大320文字にトリミングし、API の制約内に収めています。

こだわり6: パーソナライズされた状態配信

ババ抜きでは「相手の手札を知らない」ことがゲームの前提です。WebSocket で全員に同じデータを送ると、開発者ツールで他プレイヤーの手札が丸見えになってしまいます。

lambda/infrastructure/websocket/apiGatewayNotificationService.ts
async sendPersonalizedState(game: Game): Promise<void> {
  await Promise.all(
    game.players.map((player) => {
      const publicPlayers = game.players.map(
        (p) =>
          new PublicPlayer(
            p.name,
            p.avatar,
            p.seatIndex,
            p.hand.length,    // カード枚数のみ
            p.finishedOrder
          )
      );

      const msg: ServerMessage = {
        type: "game_state",
        phase: game.phase,
        yourHand: player.hand,       // 自分の手札のみ
        players: publicPlayers,       // 他プレイヤーは枚数のみ
        currentTurnSeat: game.currentTurnIndex,
      };

      return this.sendToConnection(player.connectionId, msg);
    })
  );
}

PublicPlayer は手札の 枚数 (cardCount) のみを公開し、カードの中身は含みません。

lambda/domain/model/player/publicPlayer.ts
export class PublicPlayer {
  constructor(
    private _name: string,
    private _avatar: string,
    private _seatIndex: number,
    private _cardCount: number,       // 手札の内容ではなく枚数
    private _finishedOrder: number | null
  ) {}
}

各プレイヤーに 個別のメッセージPostToConnection で送信するため、どのプレイヤーにも自分の手札しか届きません。

こだわり7: 堅牢なエラーハンドリングとリカバリ

GoneException(410)での切断プレイヤー自動クリーンアップ

API Gateway WebSocket API では、すでに切断されたコネクションに PostToConnection すると GoneException(HTTP 410)が返ります。これをキャッチして接続レコードを自動削除しています。

lambda/infrastructure/websocket/apiGatewayNotificationService.ts
async sendToConnection(
  connectionId: string,
  message: ServerMessage
): Promise<void> {
  try {
    await client.send(
      new PostToConnectionCommand({
        ConnectionId: connectionId,
        Data: JSON.stringify(message),
      })
    );
  } catch (err) {
    if (err instanceof GoneException || (err as any)?.statusCode === 410) {
      await this.connectionRepo.delete(connectionId);
    } else {
      throw err;
    }
  }
}

画像生成失敗時のマッチメイキングキュー再投入

画像生成 API はハッカソンという環境上、失敗するリスクが高い箇所です。失敗時にはプレイヤーをマッチメイキングキューに戻し、ルーレット状態も復元します。

lambda/application/useCase/joinGameUseCase.ts
try {
  const imageUrls = await this.imageGenerationService.generate(prompt, gameId);
  // ... ゲーム開始処理
} catch (err) {
  // Recovery: プレイヤーをキューに戻し、ルーレット状態も復元
  await Promise.all([
    ...matchedPlayers.map((w) =>
      this.matchmakingRepo.addPlayer(w.connectionId, w.playerName)
    ),
    this.matchmakingRepo.saveRouletteState(slots),
  ]);

  await Promise.all(
    matchedPlayers.map((w) =>
      this.notificationService.sendToConnection(w.connectionId, {
        type: "error",
        message: "ゲームの作成に失敗しました。再度マッチングを行います。",
      })
    )
  );
}

3回リトライ + 70秒タイムアウト

画像生成 API のクライアントは、3回のリトライと1リクエストあたり70秒のタイムアウトを設定しています。

lambda/infrastructure/http/httpImageGenerationService.ts
const TIMEOUT_MS = 70_000;
const MAX_RETRIES = 3;

async generate(prompt: string, roomId: string): Promise<string[]> {
  let lastError: unknown;
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
    try {
      return await this.requestImage(prompt, roomId);
    } catch (err) {
      lastError = err;
      if (attempt < MAX_RETRIES) {
        console.warn(JSON.stringify({
          event: "image_api_retry",
          roomId,
          attempt,
          maxRetries: MAX_RETRIES,
        }));
      }
    }
  }
  throw lastError;
}

Lambda のタイムアウト(90秒)> 画像 API のタイムアウト(70秒)としているため、Lambda 側でタイムアウトする前に必ず catch 節に入り、リカバリ処理を実行できます。

ゲームロジックの設計

デッキ: 13ランク中8ランクをランダム選択

標準的なババ抜きでは53枚のデッキを使いますが、4人で遊ぶには多すぎます。13ランクの中から ランダムに8ランクを選択 し、4スート × 8ランク + ジョーカー = 33枚 のコンパクトなデッキにしています。

lambda/domain/model/card/deck.ts
const DECK_RANK_COUNT = 8;

static create(): Card[] {
  const allRanks = Array.from({ length: 13 }, (_, i) => i + 1);
  const selectedRanks = Deck.shuffle(allRanks).slice(0, DECK_RANK_COUNT);

  const cards: Card[] = [];
  for (const suit of SUITS) {
    for (const rank of selectedRanks) {
      cards.push(new Card(`${suit}-${rank}`, suit, rank, RANK_LABELS[rank]));
    }
  }
  cards.push(new Card("joker", "joker", 0, "JOKER"));
  return cards;
}

Fisher-Yates シャッフル

lambda/domain/model/card/deck.ts
static shuffle<T>(arr: T[]): T[] {
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

元の配列を変更しないイミュータブルな実装です。ドメインモデル全体を通して、既存のオブジェクトを変更せず、常に新しいオブジェクトを返す 方針にしています。

ペア除去と配り

配った直後にペア(同ランクのカード2枚)を除去し、残った手札をシャッフルします。

lambda/domain/model/card/deck.ts
static deal(players: Player[]): Player[] {
  const deck = Deck.shuffle(Deck.create());
  const hands: Card[][] = players.map(() => []);
  for (let i = 0; i < deck.length; i++) {
    hands[i % players.length].push(deck[i]);
  }
  return players.map((p, i) =>
    new Player(
      p.connectionId, p.name, p.avatar, p.seatIndex,
      Deck.removePairs(hands[i]), p.finishedOrder
    )
  );
}

static removePairs(hand: Card[]): Card[] {
  const byRank = new Map<number, Card[]>();
  for (const card of hand) {
    const existing = byRank.get(card.rank) ?? [];
    existing.push(card);
    byRank.set(card.rank, existing);
  }
  const remaining: Card[] = [];
  for (const [, cards] of byRank) {
    if (cards.length % 2 === 1) {
      remaining.push(cards[0]);
    }
  }
  return Deck.shuffle(remaining);
}

カードを引く・上がり判定・ランキング

Game.drawCard はドメインロジックのコアです。カードを引き、ペアが成立すれば除去、手札が0になれば上がりを記録します。

lambda/domain/model/game/game.ts
drawCard(
  drawerIndex: number,
  targetIndex: number,
  cardIndex: number
): DrawResult {
  const hands = this._players.map((p) => [...p.hand]);

  const targetHand = hands[targetIndex];
  const [drawnCard] = targetHand.splice(cardIndex, 1);

  const drawerHand = hands[drawerIndex];
  const pairIndex = drawerHand.findIndex(
    (c) => c.rank === drawnCard.rank && c.id !== drawnCard.id
  );

  const paired = pairIndex !== -1;

  if (paired) {
    drawerHand.splice(pairIndex, 1);
  } else {
    drawerHand.push(drawnCard);
    hands[drawerIndex] = Deck.shuffle(drawerHand);
  }

  // 上がり判定(引かれた側 → 引いた側の順に処理)
  let count = this._finishedCount;
  const newlyFinished: number[] = [];

  if (hands[targetIndex].length === 0 && /* 未上がり */) {
    count++;
    finishedOrders[targetIndex] = count;
    newlyFinished.push(targetIndex);
  }
  if (hands[drawerIndex].length === 0 && /* 未上がり */) {
    count++;
    finishedOrders[drawerIndex] = count;
    newlyFinished.push(drawerIndex);
  }

  return new DrawResult(updated, count, paired, newlyFinished);
}

ランキングは「上がり順 → 残りカード数昇順 → ジョーカー保持者が最下位」のルールで決定します。

lambda/domain/model/game/game.ts
getRankings(): Ranking[] {
  const finished = this._players
    .filter(p => p.finishedOrder !== null)
    .sort((a, b) => a.finishedOrder! - b.finishedOrder!);

  const remaining = this._players.filter(p => p.finishedOrder === null);
  const hasJoker = (p: Player) => p.hand.some(c => c.suit === "joker");

  const nonJoker = remaining.filter(p => !hasJoker(p))
    .sort((a, b) => a.hand.length - b.hand.length);
  const jokerHolders = remaining.filter(p => hasJoker(p));

  const sorted = [...finished, ...nonJoker, ...jokerHolders];
  return sorted.map((p, i) =>
    new Ranking(p.seatIndex, p.name, p.avatar, i + 1)
  );
}

テスト戦略

ドメインロジック単体テスト

game-logic.test.ts では、AWS SDK に一切依存せずにドメインロジックをテストしています。

test/game-logic.test.ts
describe("Deck.create", () => {
  it("should generate 33 cards (8 ranks × 4 suits + joker)", () => {
    const deck = Deck.create();
    expect(deck).toHaveLength(33);
  });

  it("should have exactly one joker", () => {
    const deck = Deck.create();
    const jokers = deck.filter((c) => c.suit === "joker");
    expect(jokers).toHaveLength(1);
  });
});

describe("Game.drawCard", () => {
  it("should pair when drawn card matches a card in drawer's hand", () => {
    const players = [
      makePlayer(0, [card("spades", 5, "s5")]),
      makePlayer(1, [card("hearts", 5, "h5"), card("diamonds", 8, "d8")]),
    ];
    const game = makeGame(players);
    const result = game.drawCard(0, 1, 0);
    expect(result.paired).toBe(true);
    expect(result.players[0].hand).toHaveLength(0);
  });
});

describe("Game.getRankings", () => {
  it("should always place joker holder last", () => {
    const players = [
      makePlayer(0, [card("spades", 1)], { finishedOrder: null }),
      makePlayer(1, [], { finishedOrder: 1 }),
      makePlayer(2, [card("joker", 0, "joker")], { finishedOrder: null }),
      makePlayer(3, [card("diamonds", 3), card("clubs", 4)]),
    ];
    const game = makeGame(players);
    const rankings = game.getRankings();
    expect(rankings[3].seatIndex).toBe(2); // ジョーカー保持者が最下位
  });
});

aws-sdk-client-mock を使ったユースケーステスト

actions.test.ts では、aws-sdk-client-mock を使い DynamoDB と API Gateway のレスポンスをモックしています。

test/actions.test.ts
const ddbMock = mockClient(DynamoDBDocumentClient);
const apigwMock = mockClient(ApiGatewayManagementApiClient);

it("should start game when 4 players join", async () => {
  ddbMock.on(PutCommand).resolves({});
  ddbMock.on(TransactWriteCommand).resolves({});
  ddbMock.on(QueryCommand).resolves({
    Items: [
      { connectionId: "conn-0", playerName: "Alice" },
      { connectionId: "conn-1", playerName: "Bob" },
      { connectionId: "conn-2", playerName: "Charlie" },
      { connectionId: "conn-3", playerName: "Dave" },
    ],
  });
  // ...

  await joinGameUseCase.execute("conn-3", "Dave");

  const messages = getSentMessages();
  const gameStartMsgs = messages.filter((m) => m.data.type === "game_start");
  expect(gameStartMsgs).toHaveLength(4);

  // 各プレイヤーがユニークな手札を受け取ることを検証
  const hands = gameStartMsgs.map((m) => m.data.yourHand);
  const handSets = hands.map((h: any[]) =>
    new Set(h.map((c: any) => c.id))
  );
  for (let i = 0; i < handSets.length; i++) {
    for (let j = i + 1; j < handSets.length; j++) {
      const overlap = [...handSets[i]].filter((id) => handSets[j].has(id));
      expect(overlap).toHaveLength(0); // 手札の重複なし
    }
  }
});

クリーンアーキテクチャの恩恵で、テストは2層に分けられています。

テスト 対象 モック
game-logic.test.ts ドメインロジック なし(純粋なTypeScript)
actions.test.ts ユースケース全体 DynamoDB + API Gateway

クリーンアーキテクチャをハッカソン中に使うのはオーバースペックでは?

もちろんトレードオフもあります。トータルのコード量は多く、「とにかく動くものを」という速度は犠牲になります。これは事実なので、それが嫌ならばMVC, MVP, その他普通のトランザクションスクリプトで書けば良いと思います。ハッカソンはスピードと完成度が命なので、それを最優先すべきです。

ただ、クリーンアーキテクチャを導入するのとタイミングは簡単である

ただし、実は1日目の作業開始1時間目ぐらいの時にはAWS CDKのinitで生成された単一のLambdaファイルに全てのババ抜きの処理を上から順番に書く、つまりトランザクションスクリプトと呼ばれる方法で書いていました。一般的な書き方です。

もちろん、カードを引く、ルームにJoinする、などの機能ごとにaction/joinRoom.tsのようにフォルダで関数を分けてはいました。

この状態でビジネスロジックにバグがないことを53個ほどのテストで検証済みであり、WSサーバーもcdk deployで本番環境デプロイで動いていることを確認していたので、それをmainブランチに置きながら,別ブランチでクリーンアーキテクチャに移行開始しました。つまり、移行する時のポイントは以下です。

  • ビジネスロジックが複雑な場合→ビジネスロジックを切り出してテストすべき。
  • すでにビジネスロジックを踏襲し、デプロイやテストなども終わっており、時間に余裕があり、移行作業がすぐに終わる場合→実際、移行作業は2000行程の変更と1時間程度で終わらせておいた
  • 外部サービスとの接続やInfrastructure層の接続や内容、仕様が頻繁に変わる場合→今回でいえば画像生成APIなど、接続先が多く、担当者ごとに技術や仕様がバラバラの場合は特に有効
  • ハッカソンで強く見せたい場合→Clean Architectureは強く見える。実際強い。が、別に私の趣向なので必要ではない。
  • カッコつけたい場合→これは完全に自分の趣向なので必要ではない。

これらが当てはまる場合にクリーンアーキテクチャを使うと幸せになれると今回のハッカソンでは感じました。

まとめ・振り返り

2日間のハッカソンで、以下を実現できました。

  • クリーンアーキテクチャ による関心の分離と高いテスタビリティ
  • DynamoDB シングルテーブル設計 でインフラをシンプルに保つ
  • TransactWrite + 楽観的ロック による並行制御
  • パーソナライズされた状態配信 によるチート防止
  • エラーリカバリ による堅牢性の確保

「ハッカソンでクリーンアーキテクチャは正解だったか?」という問いに対する答えは Yes です。特に以下の場面で助けられました。

  • 画像生成 API の仕様がハッカソン中に変わったとき、HttpImageGenerationService だけを修正すれば済んだ→ビジネスロジックや他の処理、プログラムに一切影響を与えず、もし対応するAPIが増えればimplementsを増やせば良いだけであった
  • ゲームロジックのバグをドメイン層のテストで即座に検出・修正できた→特にWSサーバーはリアルタイム、つまり高速かつ双方向通信でStateや通信が頻繁に変わるため、ビジネスロジックを単体でテスト、Lambdaなどに依存しない設計にできたのはとてもAI駆動やハッカソンと相性が良かった
  • フロントエンドとの結合前に、ユースケースレベルでの動作確認が完了していた

Discussion