Post
JA EN

ホテル予約システムで思考実験する——DDDファーストなAIプロトタイピングをNext.js 16 + OpenNextで実装する

ホテル予約システムで思考実験する——DDDファーストなAIプロトタイピングをNext.js 16 + OpenNextで実装する
  • 想定読者: DDDファーストなAIプロトタイピング の設計論を読んで「実際にどう動くか見たい」と思った人。Next.js での実装例を見たいエンジニアと、ワークフロー全体を理解したい事業側の両方
  • 前提知識: 設計論編を先に読むことを推奨(概念定義は設計論編に置いている)。Next.js App Router の基本構造(Server Components / Server Actions)を知っているとコード例が読みやすい
  • 所要時間: 約20〜25分(コード例の読解込み)

概要

設計論編 DDDファーストなAIプロトタイピング——役割と足場で成立させる は、ワークフローの骨格を整理した。本記事は架空のホテルチェーンの予約システムを Next.js 16 (App Router) + TypeScript + Drizzle ORM で実装し、OpenNext で AWS にデプロイするシナリオを通して、その骨格を動かしてみる。

題材は単純すぎず複雑すぎないものを選んだ。誰でもイメージできる業務(予約・チェックイン・清掃)でありながら、ドメインの境界(顧客予約 / 在庫管理 / フロント業務 / 清掃)が複数あり、不変条件(同じ部屋を2件予約できない、期限切れは自動キャンセル、確定にカード認証必要)が現実的に効いてくる。

技術スタックは Next.js 16 (App Router、React 19 ベース) + TypeScript strict + Drizzle ORM + Auth.js + OpenNext on AWS(Lambda + CloudFront + S3 + Aurora Serverless v2)。これは「本番でも使える普通の技術スタック」の代表として選んだ。Vercel に縛られず、AWS 上で自社管理できる構成を採る。専用プロトタイピングツールではなく、本番システムでもそのまま使える技術でプロトを作ることが、本ワークフローの前提だ。

7つのフェーズを通して見ていく——Event Storming セッション、ドメイン定義の5要素を整える、AGENTS.md を書く、ドメイン型を先にコミット、AIプロトを作る、ガードレールCIを置く、本番化エンジニアが引き継ぎ判断をする。各フェーズで何が成果物として残り、それが次のフェーズにどう渡されるかを具体的に示す。

シナリオ:「みなと屋ホテルズ」のWeb予約システム化

架空の中小規模ホテルチェーン「みなと屋ホテルズ」を想定する。

項目内容
拠点5支店、各15〜30室
現状電話とFAX、紙の宿泊台帳で予約管理
課題Web予約ができないため海外顧客の取りこぼし、ダブルブッキングが月数件
ゴール3か月でβ版(社内検証)、6か月で本番運用
制約既存の会計システム(オンプレ)との連携が必須
技術スタックNext.js 16 (App Router、React 19) / TypeScript strict / Drizzle ORM
ホスティングOpenNext on AWS(Lambda + CloudFront + S3 + Aurora Serverless v2)
認証Auth.js(OIDC、本番は AWS Cognito 連携も検討)

役割の割り当て:

役割担当者
役割1: ドメイン定義者業務歴15年のフロント支配人 + 外部DDDコンサル
役割2: プロト作成者みなと屋のIT担当(業務に詳しい元フロントマネージャー、コードは読める)
役割3: 本番化エンジニア外部発注先のシニアエンジニア2名
役割4: AI環境整備エンジニア外部DDDコンサル(兼任、最初の1か月のみ)

役割2 はエンジニアではないが業務に精通している。これが「業務に明るいPMでもプロトを作れる」状況を成立させる前提だ。

Phase 1:Event Storming セッション

開発開始の最初の3日間で、Event Storming を実施する。参加者は、フロント支配人、清掃責任者、予約担当2名、経営者、IT担当(役割2)、DDDコンサル(役割1ファシリ)の計7名。オンラインホワイトボードに付箋を貼っていく。

1日目:ドメインイベントを時系列に並べる(オレンジ付箋)

参加者全員に「業務で実際に起きること」を時系列に書き出してもらう。最初は雑然とした付箋の海ができる:

  • 顧客から電話あり
  • Webから予約申込が来た
  • 部屋の空きを確認した
  • 仮押さえした
  • カード認証OKだった
  • 予約確定した
  • 確認メール送った
  • チェックインの時間が来た
  • 顧客が到着した
  • ルームキーを渡した
  • 滞在開始した
  • 清掃依頼が出た
  • 清掃完了した
  • チェックアウト時間が来た
  • 精算完了した
  • 予約のキャンセル連絡があった
  • 仮押さえの期限切れになった
  • ダブルブッキングが発覚した
  • ……

これを時系列に整列するだけで、すでに業務フロー全体が見えてくる。

2日目:境界線と用語の発見

イベントを並べていると、自然に「ここで話している言葉が違う」という発見が起きる:

  • 予約担当が言う「予約」と、フロントが言う「予約」が違う。前者は「仮押さえ」、後者は「確定済みの宿泊枠」を指している
  • 清掃責任者が言う「部屋」は物理的な個別の部屋(301号室、302号室)、予約担当が言う「部屋」は部屋タイプ(ダブル、ツイン)を指していることが多い
  • 経営者が言う「在庫」は売上ベース、フロントが言う「在庫」は物理的な使用可能性

この発見で ユビキタス言語の禁則 が芽生える。「予約」を全員同じ意味で使うのは不可能なので、用語を分ける:

  • Reservation(仮押さえ):カード認証前の確保された枠
  • Booking(確定予約):認証完了後の確定枠
  • Stay(滞在):チェックイン済みの実際の宿泊
  • Room(客室):物理的な個別の部屋(301号室など)
  • RoomType(客室種別):ダブル・ツインなどの種別

3日目:Bounded Context の発見

イベントの塊と用語の違いから、4つのBounded Context が浮かび上がる:

flowchart TB
    A["顧客予約<br>Customer Reservation<br>UL: Reservation, Booking"]
    B["在庫管理<br>Inventory<br>UL: Room, RoomType, Block"]
    C["フロント業務<br>Front Desk<br>UL: Stay, CheckIn, CheckOut"]
    D["清掃<br>Housekeeping<br>UL: CleaningTask, RoomStatus"]
    A -->|"在庫確認"| B
    A -->|"Booking確定通知"| C
    C -->|"清掃依頼"| D
    D -->|"清掃完了通知"| C

矢印は隣接コンテキスト間の関係。紫付箋(ポリシー・不変条件)も貼っていく:

  • 顧客予約 内:同じRoomを同じ期間に2件Bookingできない
  • 顧客予約 内:Reservationの有効期限は30分、過ぎたら自動Cancel
  • 顧客予約 内:Booking確定にはカード認証成功が必須
  • フロント業務 内:チェックイン日は Booking 日以降
  • 在庫管理 内:清掃完了前のRoomは販売不可

3日のセッション終了時、ホワイトボード上にはドメイン定義の原型ができている。

Phase 2:ドメイン定義の5要素を整える

Event Stormingの成果物を、設計論編で示した5要素に整理する。役割1が1週間かけてまとめる。

ユビキタス言語(抜粋)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Reservation]
- 文脈: 顧客予約コンテキスト
- 意味: 顧客のWeb申込後、カード認証前の仮押さえ状態
- 関連状態: Pending, Confirmed, Expired, Cancelled
- 禁則:
  - Confirmed状態を「予約成立」と表現しない(Bookingに切り替え)
  - 「予約取り消し」と「キャンセル」は同義として扱わず Cancelled に統一

[Booking]
- 文脈: 顧客予約コンテキスト、フロント業務コンテキスト
- 意味: カード認証成功後の確定枠。会計上の予約成立
- 関連状態: Confirmed, CheckedIn, CheckedOut, NoShow, Cancelled
- 禁則: Pending状態は持たない(その段階はReservation)

[Room]
- 文脈: 在庫管理コンテキスト、フロント業務コンテキスト
- 意味: 物理的な個別の客室。「301号室」など一意な識別子を持つ
- 禁則: 「部屋タイプ」の意味で使わない(→ RoomType)

[RoomType]
- 文脈: 在庫管理コンテキスト、顧客予約コンテキスト
- 意味: ダブル・ツインなどの種別。価格設定の単位
- 禁則: 個別の客室の意味で使わない(→ Room)

Bounded Context マップ

1
2
3
4
5
6
7
8
顧客予約 ─Customer/Supplier─> 在庫管理
顧客予約 ─Published Language─> フロント業務
フロント業務 ─Partnership─> 清掃
顧客予約 ─Conformist─> 会計システム(既存・オンプレ)

ACL の配置:
- 会計システムとの境界(Conformist だが既存システムの語彙汚染を防ぐ)
- 顧客予約 ↔ 在庫管理(時間枠の語彙差を吸収)

不変条件(抜粋)

1
2
3
4
5
6
INV-1: 同じ Room を同じ宿泊期間に2件以上 Booking してはならない
INV-2: Reservation は作成から30分で自動的に Expired 状態へ
INV-3: Reservation → Booking への遷移にはカード認証成功イベントが必須
INV-4: Booking のチェックイン日は Booking 作成日と同じか以降
INV-5: 清掃完了通知を受けていない Room は次の Booking を受け付けない
INV-6: Cancelled された Booking は元に戻せない(新規Reservationから始める)

ユースケース仕様(Given/When/Then)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Feature: Web予約の確定フロー

Scenario: 認証成功で確定する
  Given 顧客 C が RoomType "Double" を選んで予約を申し込んだ
  And 該当期間に空き Room が1つ以上ある
  When 顧客 C のクレジットカード認証が成功する
  Then Reservation は Booking へ遷移する
  And 該当する1つの Room がブロック状態になる
  And 顧客 C に Booking 確認メールが送られる

Scenario: 認証失敗で期限切れになる
  Given 顧客 C が RoomType "Double" を選んで予約を申し込んだ
  And 30分以内にカード認証が完了しなかった
  When 30分が経過する
  Then Reservation は Expired 状態に遷移する
  And ブロックされていた Room は再度予約可能になる

ドメインモデル(一部)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Customer Reservation Context]

Aggregate Root: Reservation
├── ReservationId, CustomerId
├── RoomTypeRequest, DateRange
├── status: Pending | Confirmed | Expired | Cancelled
├── createdAt, expiresAt (createdAt + 30分)
└── confirmedRoomId? (Confirmed時のみ)

Aggregate Root: Booking
├── BookingId, ReservationId, CustomerId, RoomId
├── DateRange
├── status: Confirmed | CheckedIn | CheckedOut | NoShow | Cancelled
├── confirmedAt, cardAuthRef
└── totalAmount

これら5要素が、役割1の成果物として整理された。1週間で。

Phase 3:AGENTS.md を書く(Next.js 前提)

役割4 が、Phase 2 の成果物を AGENTS.md に落とす。リポジトリ直下に置けば、Claude Code や Cursor が毎ターン自動でロードする。Next.js 固有のルールも明示する:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# AGENTS.md — みなと屋予約システム

## このプロジェクトの目的

中小規模ホテルチェーン「みなと屋ホテルズ」のWeb予約システム。
電話・FAX・紙台帳ベースの運用をオンライン化する。

## 技術スタック

- Next.js 16 (App Router、React 19 ベース) + TypeScript strict
- Drizzle ORM + PostgreSQL (Aurora Serverless v2)
- Auth.js — 顧客認証とフロント職員認証
- **ホスティング: OpenNext on AWS** (Lambda + CloudFront + S3)
- 定期実行: AWS EventBridge → Lambda(Reservation 期限切れ処理等)
- React Server Components (RSC) を基本、必要箇所のみ Client Component
- フォーム入力は Server Actions 経由
- IaC: AWS CDK(OpenNext の CDK construct を利用)

## ディレクトリ構造(必ず遵守)

src/
├── app/ — Next.js ルーティング層のみ。ドメインロジックを置かない
│   ├── (public)/ — 顧客向けページ
│   ├── (admin)/ — フロント職員向け
│   └── api/ — Webhook 用 Route Handler
├── contexts/ — Bounded Context ごとのドメイン層
│   ├── customer-reservation/
│   │   ├── domain/ — ドメイン型・ロジック(純粋関数)
│   │   ├── use-cases/ — ユースケース(Server Action として export 可)
│   │   ├── infrastructure/ — Drizzle スキーマと Repository 実装
│   │   └── acl/ — 他コンテキスト・外部システムとの境界
│   ├── inventory/
│   ├── front-desk/
│   └── housekeeping/
├── lib/ — 共通ユーティリティ(クロスカット)
└── components/ — 汎用 React Components

クロスコンテキストの import は ACL層経由でのみ可能。
それ以外の直接 import はCIで検出して fail させます。

## ユビキタス言語(必読)

以下の用語は意味が固定されています。同義語への置き換えを禁止します。

| 用語 | 意味 | 禁則 |
|---|---|---|
| Reservation | カード認証前の仮押さえ。30分で期限切れ | Bookingと混同しない |
| Booking | カード認証成功後の確定予約 | Pending状態を持たない |
| Stay | チェックイン済みの実際の滞在 | Bookingと同義にしない |
| Room | 物理的な個別客室(301号室など) | RoomTypeと混同しない |
| RoomType | 客室種別(Double, Twin等)。価格単位 | Roomの意味で使わない |

完全な用語集は `/docs/ubiquitous-language.md` を参照。

## 不変条件(絶対遵守)

- INV-1: 同じ Room を同じ期間に2件以上 Booking しない
- INV-2: Reservation は30分で自動 Expired
- INV-3: Booking 遷移にはカード認証成功イベントが必須
- INV-4: Booking のチェックイン日 ≥ Booking 作成日
- INV-5: 清掃完了前の Room は次の Booking 不可
- INV-6: Cancelled された Booking は元に戻せない

INV違反は型エラーまたはプロパティテスト失敗で必ず検出されるべきです。

## Next.js 16 固有のルール

- React 19 ベース。Server Action 結果のフォーム連携は `useActionState``useFormState` は React 19 で deprecated、将来削除予定。新規実装は使用禁止)
- ドメイン層(contexts/<ctx>/domain/)には 'use client' / 'use server'
  を書かない。純粋なロジックのみ
- ユースケース層を Server Action として使う場合のみ、export する
  ファイル先頭に 'use server' を書く
- Client Component からドメイン型を import するときは Value Object のみ可。
  Aggregate Root の参照は禁止(Server Component → Client Component への
  プロップス受け渡しは DTO に変換すること)
- データ取得は Server Component の async function 内で直接 Repository 呼出。
  fetch('/api/...') 経由の自分自身呼出は禁止
- フォーム送信は Server Action 一択。クライアント側 fetch を書かない
- DB アクセスは必ず contexts/<ctx>/infrastructure/repository.ts 経由。
  Server Component から直接 Drizzle を呼ばない

## OpenNext on AWS 固有のルール

- Lambda 環境を前提に、長時間処理(30秒超)は EventBridge → Lambda の
  非同期パターンに分解する
- ファイルシステム書き込みは禁止(Lambda は読み取り専用)。一時ファイルは /tmp のみ
- 環境変数は AWS SSM Parameter Store / Secrets Manager から取得し、
  CDK で Lambda に注入
- Aurora Serverless v2 への接続はコネクションプール(RDS Proxy 経由)必須
- Image Optimization は OpenNext 標準の Lambda 経由、または CloudFront +
  Lambda@Edge で構成
- 静的アセットは next build の出力を S3 にアップロード、CloudFront から配信

## コーディング規約

- TypeScript strict mode 必須
- ドメイン型は src/contexts/<ctx>/domain/ に既にコミットされています。
  変更前に必ず質問してください
- 状態遷移は discriminated union で表現
- 不明なドメイン用語があれば、推測せず質問してください

これがリポジトリ直下に置かれれば、役割2が「予約申込ページを作って」とAIに頼んだ瞬間に、AIはこのAGENTS.mdを参照しながら、適切な用語・境界・Next.js 構造を踏まえて実装する。

Phase 4:ドメイン型を先にコミット

役割4 がドメイン型を TypeScript で先にリポジトリにコミットする。これが効くのは、型違反のコードは AI が生成した時点でビルドエラーになるからだ。

src/contexts/customer-reservation/domain/types.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// === Value Objects(Client/Server 両方で使える純粋型)===

export type ReservationId = string & { readonly _brand: "ReservationId" };
export type BookingId = string & { readonly _brand: "BookingId" };
export type CustomerId = string & { readonly _brand: "CustomerId" };
export type RoomId = string & { readonly _brand: "RoomId" };

export type RoomType = "Single" | "Double" | "Twin" | "Suite";

export interface DateRange {
  readonly checkIn: Date;
  readonly checkOut: Date;
}

export interface Money {
  readonly amount: number;
  readonly currency: "JPY";
}

// === Aggregate: Reservation(discriminated union で状態を表現)===

export type Reservation =
  | {
      readonly status: "Pending";
      readonly id: ReservationId;
      readonly customerId: CustomerId;
      readonly roomTypeRequest: RoomType;
      readonly dateRange: DateRange;
      readonly createdAt: Date;
      readonly expiresAt: Date;
    }
  | {
      readonly status: "Confirmed";
      readonly id: ReservationId;
      readonly customerId: CustomerId;
      readonly roomTypeRequest: RoomType;
      readonly dateRange: DateRange;
      readonly createdAt: Date;
      readonly confirmedAt: Date;
      readonly confirmedRoomId: RoomId;
    }
  | {
      readonly status: "Expired";
      readonly id: ReservationId;
      readonly customerId: CustomerId;
      readonly createdAt: Date;
      readonly expiredAt: Date;
    }
  | {
      readonly status: "Cancelled";
      readonly id: ReservationId;
      readonly customerId: CustomerId;
      readonly createdAt: Date;
      readonly cancelledAt: Date;
      readonly reason: string;
    };

// === ドメインロジック(純粋関数。Server Component からも Server Action からも呼べる)===

export const RESERVATION_TTL_MINUTES = 30;

export function isExpired(r: Reservation, now: Date): boolean {
  if (r.status !== "Pending") return false;
  return now >= r.expiresAt;
}

export function canConfirm(r: Reservation): r is Reservation & { status: "Pending" } {
  return r.status === "Pending";
}

src/contexts/customer-reservation/infrastructure/schema.ts(Drizzle スキーマも事前定義):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";

export const reservationStatusEnum = pgEnum("reservation_status", [
  "Pending", "Confirmed", "Expired", "Cancelled"
]);

export const reservations = pgTable("reservations", {
  id: text("id").primaryKey(),
  customerId: text("customer_id").notNull(),
  status: reservationStatusEnum("status").notNull(),
  roomTypeRequest: text("room_type_request").notNull(),
  checkIn: timestamp("check_in").notNull(),
  checkOut: timestamp("check_out").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  expiresAt: timestamp("expires_at"),
  confirmedAt: timestamp("confirmed_at"),
  confirmedRoomId: text("confirmed_room_id"),
  // ...
});

ドメイン型と Drizzle スキーマを両方先にコミットしておくことで、AI は実装段階でこの構造に従わざるを得ない。型違反は tsc --noEmit で弾かれる。

Phase 5:AIプロト作成(Next.js 実装)

ここで初めて役割2(業務に詳しいIT担当)が登場する。Claude Code を立ち上げて、AGENTS.md と型が置かれたリポジトリで作業を始める。

実際のやり取り(再現するとこんな感じになる、という想定例。実プロジェクトのログではない):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
役割2: 顧客がWebから予約を申し込むページを作ってください。
       RoomType と日付を選んだら Pending Reservation が作成される。
       在庫確認はモックでOK。

Claude Code: AGENTS.md を読み、ドメイン型を確認します...

→ src/contexts/customer-reservation/domain/types.ts を読み込みました
→ Next.js App Router 構造を確認しました
→ INV-1〜INV-6 を確認しました

実装方針:
1. app/(public)/reservations/new/page.tsx — RSC でフォーム表示
2. app/(public)/reservations/new/actions.ts — Server Action
3. contexts/customer-reservation/use-cases/create-reservation.ts — ユースケース(純粋関数)
4. contexts/customer-reservation/infrastructure/repository.ts — Drizzle 経由の永続化
5. contexts/customer-reservation/acl/inventory-port.ts — 在庫モック差込口
6. tests/customer-reservation/create-reservation.property.test.ts — プロパティテスト

実装してよろしいですか?

役割2 は「OK」と返すだけ。Claude Code が以下を生成する:

src/app/(public)/reservations/new/page.tsx(Server Component):

1
2
3
4
5
6
7
8
9
10
11
import { CreateReservationForm } from "./form";

export default async function NewReservationPage() {
  // 公開ページなので認証不要、RSC で静的に描画
  return (
    <main>
      <h1>予約申込</h1>
      <CreateReservationForm />
    </main>
  );
}

src/app/(public)/reservations/new/form.tsx(Client Component の必要最小限。React 19 の useActionState を使用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"use client";

import { useActionState } from "react";
import { createReservationAction } from "./actions";

export function CreateReservationForm() {
  const [state, formAction, isPending] = useActionState(createReservationAction, null);

  return (
    <form action={formAction}>
      <label>客室種別
        <select name="roomType">
          <option value="Double">ダブル</option>
          <option value="Twin">ツイン</option>
        </select>
      </label>
      <label>チェックイン<input type="date" name="checkIn" /></label>
      <label>チェックアウト<input type="date" name="checkOut" /></label>
      <button type="submit" disabled={isPending}>
        {isPending ? "処理中..." : "予約申込"}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.reservationId && <p>仮押さえしました(30分以内に決済を完了してください)</p>}
    </form>
  );
}

src/app/(public)/reservations/new/actions.ts(Server Action):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
"use server";

import { createReservation } from "@/contexts/customer-reservation/use-cases/create-reservation";
import type { CustomerId, RoomType } from "@/contexts/customer-reservation/domain/types";
import { getCurrentCustomerId } from "@/lib/auth";

type ActionResult =
  | { reservationId: string }
  | { error: string };

export async function createReservationAction(
  _prev: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const customerId = await getCurrentCustomerId() as CustomerId;
  const roomType = formData.get("roomType") as RoomType;
  const checkIn = new Date(formData.get("checkIn") as string);
  const checkOut = new Date(formData.get("checkOut") as string);

  const result = await createReservation({
    customerId,
    roomType,
    dateRange: { checkIn, checkOut },
    now: new Date(),
  });

  if (result.kind === "error") {
    return { error: result.message };
  }
  return { reservationId: result.reservation.id };
}

src/contexts/customer-reservation/use-cases/create-reservation.ts(純粋関数、Next.js 非依存):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import type { CustomerId, RoomType, DateRange, Reservation, ReservationId } from "../domain/types";
import { RESERVATION_TTL_MINUTES } from "../domain/types";
import { inventoryPort } from "../acl/inventory-port";
import { reservationRepository } from "../infrastructure/repository";
import { randomUUID } from "crypto";

export type CreateReservationInput = {
  customerId: CustomerId;
  roomType: RoomType;
  dateRange: DateRange;
  now: Date;
};

export type CreateReservationResult =
  | { kind: "success"; reservation: Reservation & { status: "Pending" } }
  | { kind: "error"; message: string };

export async function createReservation(
  input: CreateReservationInput
): Promise<CreateReservationResult> {
  // INV-4: checkInは現在以降
  if (input.dateRange.checkIn < input.now) {
    return { kind: "error", message: "チェックイン日は今日以降を指定してください" };
  }

  // 在庫確認(ACL経由)
  const hasInventory = await inventoryPort.checkAvailability(
    input.roomType,
    input.dateRange
  );
  if (!hasInventory) {
    return { kind: "error", message: "ご希望期間に空室がありません" };
  }

  // INV-2: expiresAt = createdAt + 30min
  const expiresAt = new Date(input.now.getTime() + RESERVATION_TTL_MINUTES * 60 * 1000);

  const reservation: Reservation = {
    status: "Pending",
    id: randomUUID() as ReservationId,
    customerId: input.customerId,
    roomTypeRequest: input.roomType,
    dateRange: input.dateRange,
    createdAt: input.now,
    expiresAt,
  };

  await reservationRepository.save(reservation);
  return { kind: "success", reservation };
}

役割2 はこのコードを読んで、業務的に妥当かを確認する。「30分の有効期限はOK、在庫モックも妥当」と判断したら次のユースケースへ。Server Action から Use Case が呼ばれ、Use Case から ACL と Repository が呼ばれる構造になっている。ドメイン型は Server / Client 両方で参照できる。

ここで重要なのは、役割2 は1行もコードを書いていないことだ。AGENTS.md と型と Drizzle スキーマが事前に正しく置かれているので、AI は Next.js の構造に従って適切な実装を生成する。役割2 の仕事は「業務的に妥当か」だけを判断すること。

数日かけて、主要なユースケースが揃ってくる:予約申込、カード認証 Webhook(Route Handler)、期限切れ自動キャンセル(AWS EventBridge → 専用 Lambda、5分間隔で起動)、ユーザー操作によるキャンセル(Server Action)。社内デモで使える状態になる。

Phase 6:ガードレールCIを置く

役割4 が GitHub Actions でガードレールCIを設定する。

.github/workflows/guardrails.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
name: Guardrails CI

on: [push, pull_request]

jobs:
  ubiquitous-language:
    name: ユビキタス言語の禁則チェック
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check forbidden synonyms
        run: |
          # Pending状態の Booking は存在しないはず(→ Reservation)
          if grep -rE "Booking.*['\"]Pending['\"]" src/ ; then
            echo "ERROR: Booking に Pending 状態は存在しません"
            exit 1
          fi
          # Room と RoomType の混同
          if grep -rE "Room\b.*=.*['\"](Single|Double|Twin|Suite)['\"]" src/ ; then
            echo "ERROR: Room(個別客室)に RoomType(種別)を代入しています"
            exit 1
          fi

  bounded-context:
    name: Bounded Context 境界チェック
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check cross-context imports
        run: |
          # contexts/<a>/ から contexts/<b>/ への直接 import は ACL以外禁止
          ! grep -rE "from ['\"]@/contexts/[^/]+/(domain|use-cases|infrastructure)" src/contexts/ \
            | grep -v "/acl/" \
            | grep -v "$(basename $(dirname $(grep -l . )))"

  nextjs-boundaries:
    name: Next.js Server/Client 境界チェック
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check 'use client' / 'use server' misuse
        run: |
          # ドメイン層に 'use client'/'use server' があってはいけない
          if grep -rE "^['\"]use (client|server)['\"]" src/contexts/*/domain/ ; then
            echo "ERROR: domain 層に 'use client'/'use server' があります"
            exit 1
          fi
          # Server Component で fetch('/api/...') 自分自身呼出は禁止
          if grep -rE "fetch\(['\"]\/api\/" src/app/ ; then
            echo "ERROR: Server Component から自分の /api を fetch しないでください"
            exit 1
          fi

  type-check:
    name: TypeScript strict 型チェック
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx tsc --noEmit --strict

  property-tests:
    name: 不変条件のプロパティテスト
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:property

これで、AI が用語を意訳したり境界を越境したり、Next.js の構造を踏み外したりすると CI が落ちる。役割2 が「動いてる」とだけ判断してマージしようとしても、CIに止められる。役割2 の目利き能力に依存しない安全網が機能する。

Phase 7:本番化エンジニアによる引き継ぎ判断

3か月のβ運用後、役割3(本番化エンジニア2名)が引き継ぎに入る。プロト作成者から渡されるのは:

  1. リポジトリ全体(コード + AGENTS.md + 型 + Drizzle スキーマ + テスト + CI)
  2. ドメイン定義のドキュメント(ユビキタス言語、Bounded Context、不変条件、ユースケース仕様)
  3. β運用中に発見されたドメイン知識の更新メモ
  4. 既知の制約リスト(「会計システム連携は手動コピペ運用中」など)

役割3 はNext.js 構造の各部品で「捨てる/残す」を判断する

部品判断理由
contexts/*/domain/types.ts(ドメイン型)そのまま残すTypeScript strict で書かれている、プロパティテスト通過、状態遷移が discriminated union で表現済み
contexts/*/use-cases/*.ts(ユースケース)大部分残す純粋関数として書かれていて Next.js / AWS 非依存。本番向けにエラーハンドリング・ロギングを強化
contexts/*/infrastructure/repository.ts(Drizzle)大部分残すDrizzle スキーマは事前定義済みで本番Aurora DBと整合。RDS Proxy 経由の接続プールに切り替え
contexts/*/acl/*.ts(ACL)モック部分を本番版に書き直しプロト時はモック、本番は実システム連携(会計システム連携も含む)
app/(public)/**(顧客向けページ)デザインを差し替え、ロジックは残すプロトのUIは検証用、本番デザイナーのデザインに置き換える。Server Action はそのまま使う
app/(admin)/**(フロント職員向け)大部分残すフロント業務での操作性は β運用で検証済み
app/api/webhooks/**(Route Handler)拡張して残す署名検証・冪等性・リトライ処理を追加。CloudFront 経由でレート制限も
定期実行 Lambda(期限切れ処理等)拡張して残すEventBridge スケジュール + DLQ + アラート追加
認証(Auth.js 設定)本番向けに強化顧客側は OAuth プロバイダ追加、職員側は AWS Cognito + MFA
OpenNext / CDK 構成本番化プロト時は単一環境、本番は dev/staging/prod のステージ分離、Aurora のリードレプリカ、CloudFront キャッシュ戦略を本格化
監視・ロギング新規実装CloudWatch Logs + X-Ray + Sentry + OpenTelemetry
CI/CD拡張して残す既存ガードレールに加え、CDK デプロイ・Blue/Green切替・ロールバックを追加

結果として、ドメイン型・ユースケース層・Drizzleスキーマ・Server Action の構造はそのまま本番に持ち上がる。書き直すべき部分(認証強化、UIデザイン、ACLの実装差替え、運用基盤)も、Next.js の構造に沿って分離されているのでどこを書き直せばいいかが明確だ。

ドキュメント(特に AGENTS.md)はそのまま本番開発でも使い続ける。本番化エンジニアもAIアシストを使うため、AGENTS.mdを引き継いでAIに同じドメイン知識・Next.jsルールを持たせることで、生成されるコードの一貫性が保たれる。

振り返り:所要時間と成果物

フェーズ所要時間関与役割
1. Event Storming セッション3日役割1 + 業務関係者7名
2. 5要素の整理1週間役割1
3. AGENTS.md 作成2日役割4
4. ドメイン型 + Drizzle スキーマの事前コミット3日役割4
5. AIプロト作成(Next.js 実装)2〜3週間役割2(単独)
6. ガードレールCI 設定1日役割4(Phase 5と並行)
7. β運用+発見の還流1か月役割2 + 業務関係者
8. 本番化2〜3か月役割3

設計から本番運用まで約6か月(シナリオ上の想定値、各フェーズは並行・手戻りを織り込む)。同規模システムを従来のウォーターフォール(要件定義 → 基本設計 → 詳細設計 → 実装 → テスト)で進めると、経験的に8〜12か月程度かかることが多い規模だ——ただしこれは経験則であり、プロジェクト規模・チーム体制・既存資産の有無で大きく変動する点は留保しておく。

最大の効率化ポイントはPhase 5(プロト作成)に役割2が単独で入れること。役割4 が事前に AGENTS.md・型・Drizzle スキーマを整えているので、エンジニアでない役割2 でも Next.js の構造に沿って 2〜3週間で主要ユースケースを実装できる。

もう一つの効率化ポイントはPhase 7 の引き継ぎが早いこと。ドメイン型・ユースケース・Drizzle スキーマ・Server Action の構造がそのまま本番にスケールするので、役割3 はゼロから書き直さずに済む。引き継ぎ判断もシンプル(部品レベルで「これは残す、これは書き直す」のリストアップで終わる)。

落とし穴は本当に起きたか

設計論編で挙げたこの手法固有の落とし穴4つを、このシナリオに当てはめると、いずれも「典型的に起きうるパターン」として再現できる(実プロジェクトで4つ全部が同時に起きるとは限らないが、それぞれは現実的に起きる):

落とし穴1(定義品質が結果に伝播): Phase 2 で「キャンセル料」の業務ルールを定義し忘れていた。Phase 5 のプロトでは「無料キャンセル可能」として実装され、β運用で経営者が「収益的に困る」と気づいた。Phase 2 に戻ってルールを追加。

落とし穴2(発見の還流): β運用中に「団体予約は通常予約と別フロー」という業務知識が表面化。最初は誰もその区別を意識していなかった。発見した役割2 が AGENTS.md と Bounded Context マップを更新(団体予約コンテキストを新設)し、src/contexts/group-reservation/ ディレクトリを追加。

落とし穴3(境界の早期固定): Phase 1 で「フロント業務」と「清掃」を別コンテキストにしたが、β運用で「実は同じチームが両方やっている小規模支店もある」と判明。境界を「組織」ではなく「責務」で引き直した(フロント業務には残しつつ、清掃通知の経路だけ統合)。

落とし穴4(過剰定義): 最初は役割1 が「全業務を完璧に Event Storming で発見してから渡したい」と主張したが、Phase 2 で時間を区切らないとプロト着手が遅れる、と判断。「最小起動セット」として顧客予約・在庫管理の2コンテキストから始め、フロント業務と清掃は β運用後に統合した。

これらの落とし穴に対処の枠組みがあったので、いずれも致命的にはならなかった。これが設計論編で示した「役割と足場で成立させる」の実際の効用だ。

まとめ

ホテル予約システムを Next.js 16 + TypeScript + Drizzle で実装し、OpenNext で AWS にデプロイするシナリオを通して、DDDファーストなAIプロトタイピングの実際を見てきた。

設計論編で示した骨格——5要素・4パターン・3+1役割・足場・落とし穴——は、それぞれ具体的な作業フェーズと Next.js 構造に対応する:

  • 5要素 → Phase 2 の成果物
  • 4パターンのうち Pattern 2(AGENTS.md)と Pattern 3(ドメイン型 + Drizzle スキーマ) → Phase 3, 4
  • 3+1役割 → 全フェーズの担当割り
  • 足場 → Phase 3, 4, 6(AGENTS.md・型・CI)
  • 落とし穴 → β運用中に発生・対処

役割2 がエンジニアでないにもかかわらず Phase 5 で単独でプロト作成を進められたのが、このワークフローの最大の特徴だ。それを成立させているのは、役割4 が Phase 3, 4, 6 で組んだ足場——AGENTS.md(Next.js 固有のルールを含む)、ドメイン型 + Drizzle スキーマ、ガードレールCI。

Next.js 16 + OpenNext on AWS の効用は明確だ:Server / Client 境界、Server Action、Route Handler、Drizzle スキーマという普通の本番技術スタックの構造が、Vercel に依存せず AWS 上でそのまま本番にスケールする。Phase 7 の引き継ぎでドメイン型・ユースケース・Drizzle スキーマがほぼ無傷で使える。「本番技術でプロトを作る」の効用が、Next.js の構造と AWS の運用基盤を介して具体的に効く。

設計論と実践を行き来できると、自分のプロジェクトに適用する判断が立つ。例えば「うちは役割1 と役割4 を兼任できる人がいない」「うちは Bounded Context が2つしかないから Phase 1 はもっと短くていい」「うちはホスティング先が AWS でなく GCP / Cloudflare だが、OpenNext の代わりに @cloudflare/next-on-pages を使えば構造は同じ」「うちは Next.js じゃなく Spring Boot だが、AGENTS.md と Aggregate の構造は同じ」など、自社の状況に合わせた変形ができる。

それが本ワークフローの目指すゴール——「AI時代の高速コーディングを活かしてプロジェクトを成功させる」具体的な処方箋だ。

関連記事

このテーマに関連する他の記事もご覧ください:

This post is licensed under CC BY 4.0 by the author.