Post
JA EN

A Thought Experiment with a Hotel Reservation System: Implementing DDD-First AI Prototyping in Next.js 16 + OpenNext

A Thought Experiment with a Hotel Reservation System: Implementing DDD-First AI Prototyping in Next.js 16 + OpenNext
  • Intended readers: People who read the design-theory companion DDD-First AI Prototyping and thought, “I’d like to see what this actually looks like in motion.” Both engineers who want a Next.js implementation example, and business-side people who want to understand the workflow end to end.
  • Assumed background: We recommend reading the design-theory companion article first (concept definitions live there). If you know the basics of the Next.js App Router (Server Components / Server Actions), the code examples will be easier to follow.
  • Estimated reading time: Around 20–25 minutes (including the code examples).

Overview

The design-theory companion, DDD-First AI Prototyping: Making It Work Through Roles and Scaffolding, laid out the skeleton of the workflow. This article runs that skeleton through a concrete scenario: a fictitious hotel chain’s reservation system, implemented in Next.js 16 (App Router) + TypeScript + Drizzle ORM, and deployed to AWS via OpenNext.

The subject was chosen to be neither too trivial nor too complex. The business (reservation, check-in, housekeeping) is something anyone can picture, yet there are several domain boundaries (Customer Reservation / Inventory / Front Desk / Housekeeping), and the invariants (no double-booking of the same room, automatic cancellation on expiry, card authorization required for confirmation) bite in realistic ways.

The technology stack is Next.js 16 (App Router, React 19-based) + TypeScript strict + Drizzle ORM + Auth.js + OpenNext on AWS (Lambda + CloudFront + S3 + Aurora Serverless v2). I picked it as a representative example of “an ordinary stack you can also run in production.” Rather than locking into Vercel, we take a configuration we can manage ourselves on AWS. Prototyping with the same technology you can use in production — not with a dedicated prototyping tool — is a precondition of this workflow.

We will walk through seven phases: Event Storming sessions, organizing the five domain-definition elements, writing AGENTS.md, committing domain types first, building the AI prototype, putting guardrail CI in place, and handing off to the production engineer. At each phase, I will show concretely what the deliverable is, and how it flows into the next phase.

Scenario: “Minato Hotels” Goes Web-Based

Imagine a fictitious small-to-mid-sized hotel chain, “Minato Hotels.”

ItemDescription
Locations5 branches, 15–30 rooms each
Current stateReservations are managed by phone, fax, and paper guest ledgers
Pain pointsNo web reservations, losing overseas customers; several double-bookings per month
GoalBeta version (internal validation) in 3 months, production in 6 months
ConstraintsIntegration with the existing accounting system (on-prem) is mandatory
Tech stackNext.js 16 (App Router, React 19) / TypeScript strict / Drizzle ORM
HostingOpenNext on AWS (Lambda + CloudFront + S3 + Aurora Serverless v2)
AuthenticationAuth.js (OIDC; AWS Cognito integration considered for production)

Role assignments:

RolePerson
Role 1: Domain definerFront-desk general manager with 15 years on the job + an external DDD consultant
Role 2: Prototype builderMinato’s IT lead (a former front-desk manager who knows the business and can read code)
Role 3: Production engineerTwo senior engineers from an external vendor
Role 4: AI environment engineerThe external DDD consultant (cross-staffed, first month only)

Role 2 is not an engineer but knows the business cold. That is the precondition that makes “even a PM who knows the business can build the prototype” feasible.

Phase 1: Event Storming Sessions

In the first three days after kickoff, we run Event Storming. Participants: the front-desk general manager, the housekeeping lead, two reservation staff, the owner, the IT lead (Role 2), and the DDD consultant (Role 1, facilitating). Seven people in total, posting sticky notes on an online whiteboard.

Day 1: Lay out domain events on a timeline (orange stickies)

Each participant writes down “things that actually happen in the business” along a timeline. At first it’s a chaotic sea of stickies:

  • Customer calls in
  • Web reservation request comes in
  • Room availability checked
  • Tentative hold placed
  • Card authorization OK
  • Reservation confirmed
  • Confirmation email sent
  • Check-in time arrives
  • Customer arrives
  • Room key handed over
  • Stay begins
  • Cleaning request issued
  • Cleaning completed
  • Check-out time arrives
  • Settlement complete
  • Cancellation call comes in
  • Tentative hold expires
  • Double-booking discovered

Just arranging these in chronological order is enough to see the whole flow.

Day 2: Discovering boundaries and terminology

As events get lined up, you naturally start to notice “we’re using different words for the same thing”:

  • The “reservation” the booking staff talk about and the “reservation” the front desk talks about are different. The former is a “tentative hold”; the latter is a “confirmed lodging slot.”
  • The “room” the housekeeping lead refers to is a physical individual room (Room 301, Room 302), while the “room” the reservation staff refer to is usually a room type (Double, Twin).
  • “Inventory” to the owner is revenue-based; “inventory” to the front desk is physical usability.

This discovery seeds ubiquitous language taboos. Because it’s impossible to make everyone use “reservation” the same way, we split the terms:

  • Reservation (tentative hold): a slot secured before card authorization
  • Booking (confirmed reservation): a slot finalized after card authorization succeeds
  • Stay: an actual stay after check-in
  • Room (guest room): a physical individual room (e.g., Room 301)
  • RoomType: a room category such as Double or Twin

Day 3: Discovering Bounded Contexts

From the clusters of events and the terminology differences, four Bounded Contexts emerge:

flowchart TB
    A["Customer Reservation<br>UL: Reservation, Booking"]
    B["Inventory<br>UL: Room, RoomType, Block"]
    C["Front Desk<br>UL: Stay, CheckIn, CheckOut"]
    D["Housekeeping<br>UL: CleaningTask, RoomStatus"]
    A -->|"Inventory check"| B
    A -->|"Booking confirmation notification"| C
    C -->|"Cleaning request"| D
    D -->|"Cleaning completion notification"| C

Arrows indicate the relationships between adjacent contexts. We also place purple stickies for policies and invariants:

  • Within Customer Reservation: the same Room cannot be Booked twice for the same period
  • Within Customer Reservation: a Reservation expires after 30 minutes and is automatically Cancelled
  • Within Customer Reservation: confirming a Booking requires a successful card authorization
  • Within Front Desk: the check-in date must be on or after the Booking date
  • Within Inventory: a Room cannot be sold before cleaning is complete

By the end of three days of sessions, the whiteboard holds a prototype of the domain definition.

Phase 2: Organizing the Five Domain-Definition Elements

We organize the Event Storming output into the five elements introduced in the design-theory companion. Role 1 spends one week on this.

Ubiquitous Language (excerpt)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[Reservation]
- Context: Customer Reservation context
- Meaning: Tentative hold after a customer's web request, before card authorization
- Related states: Pending, Confirmed, Expired, Cancelled
- Taboos:
  - Do not call the Confirmed state "the reservation is finalized" (switch to Booking)
  - Do not treat "reservation cancellation" and "cancel" as synonyms; unify under Cancelled

[Booking]
- Context: Customer Reservation context, Front Desk context
- Meaning: A confirmed slot after card authorization succeeds. The accounting-level reservation
- Related states: Confirmed, CheckedIn, CheckedOut, NoShow, Cancelled
- Taboos: Has no Pending state (that stage belongs to Reservation)

[Room]
- Context: Inventory context, Front Desk context
- Meaning: A physical individual guest room. Has a unique identifier like "Room 301"
- Taboos: Do not use to mean "room type" (→ RoomType)

[RoomType]
- Context: Inventory context, Customer Reservation context
- Meaning: A category such as Double or Twin. The unit of pricing
- Taboos: Do not use to mean an individual guest room (→ Room)

Bounded Context Map

1
2
3
4
5
6
7
8
9
10
Customer Reservation ─Customer/Supplier─> Inventory
Customer Reservation ─Published Language─> Front Desk
Front Desk           ─Partnership─> Housekeeping
Customer Reservation ─Conformist─> Accounting System (existing, on-prem)

ACL placement:
- At the boundary with the accounting system (Conformist, but we prevent the
  existing system's vocabulary from contaminating ours)
- Between Customer Reservation and Inventory (absorb the time-window
  vocabulary differences)

Invariants (excerpt)

1
2
3
4
5
6
INV-1: The same Room must not be Booked more than once for the same stay period
INV-2: A Reservation automatically transitions to Expired 30 minutes after creation
INV-3: Transitioning from Reservation to Booking requires a successful card-authorization event
INV-4: A Booking's check-in date must be on or after the Booking creation date
INV-5: A Room that has not received a cleaning-completion notification must not accept the next Booking
INV-6: A Cancelled Booking cannot be reverted (start from a new Reservation)

Use-Case Specifications (Given/When/Then)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Feature: Web reservation confirmation flow

Scenario: Confirmed via successful authorization
  Given customer C has applied for a reservation with RoomType "Double"
  And at least one Room is available for that period
  When customer C's credit-card authorization succeeds
  Then the Reservation transitions to Booking
  And one matching Room is placed in a blocked state
  And a Booking confirmation email is sent to customer C

Scenario: Expired due to failed authorization
  Given customer C has applied for a reservation with RoomType "Double"
  And card authorization did not complete within 30 minutes
  When 30 minutes elapse
  Then the Reservation transitions to the Expired state
  And the blocked Room becomes available for booking again

Domain Model (partial)

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 minutes)
└── confirmedRoomId? (only when Confirmed)

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

These five elements, organized by Role 1, are the deliverable. In about a week.

Phase 3: Writing AGENTS.md (Next.js-specific)

Role 4 distills the Phase 2 output into AGENTS.md. Place it at the repository root and Claude Code or Cursor will auto-load it every turn. Next.js-specific rules are made explicit:

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
102
103
104
# AGENTS.md — Minato Hotels Reservation System

## Project purpose

Web reservation system for the small-to-mid-sized hotel chain "Minato Hotels."
Bring an operation based on phone, fax, and paper ledgers online.

## Tech stack

- Next.js 16 (App Router, React 19-based) + TypeScript strict
- Drizzle ORM + PostgreSQL (Aurora Serverless v2)
- Auth.js — customer authentication and front-desk staff authentication
- **Hosting: OpenNext on AWS** (Lambda + CloudFront + S3)
- Scheduled tasks: AWS EventBridge → Lambda (Reservation expiry processing, etc.)
- React Server Components (RSC) by default; Client Components only where needed
- Form input via Server Actions
- IaC: AWS CDK (uses OpenNext's CDK construct)

## Directory structure (must be followed)

src/
├── app/ — Next.js routing layer only. Do not put domain logic here
│   ├── (public)/ — customer-facing pages
│   ├── (admin)/ — front-desk staff pages
│   └── api/ — Route Handlers for webhooks
├── contexts/ — domain layer, one folder per Bounded Context
│   ├── customer-reservation/
│   │   ├── domain/ — domain types and logic (pure functions)
│   │   ├── use-cases/ — use cases (may be exported as Server Actions)
│   │   ├── infrastructure/ — Drizzle schema and Repository implementations
│   │   └── acl/ — boundary with other contexts / external systems
│   ├── inventory/
│   ├── front-desk/
│   └── housekeeping/
├── lib/ — shared utilities (cross-cutting)
└── components/ — generic React components

Cross-context imports are allowed only via the ACL layer.
Any other direct import is detected by CI and fails the build.

## Ubiquitous language (required reading)

The terms below have fixed meanings. Substituting synonyms is prohibited.

| Term | Meaning | Taboos |
|---|---|---|
| Reservation | Tentative hold before card authorization. Expires after 30 minutes | Do not conflate with Booking |
| Booking | Confirmed reservation after successful card authorization | Has no Pending state |
| Stay | Actual stay after check-in | Do not treat as a synonym of Booking |
| Room | Physical individual guest room (e.g., Room 301) | Do not conflate with RoomType |
| RoomType | Room category (Double, Twin, etc.). Unit of pricing | Do not use to mean Room |

See `/docs/ubiquitous-language.md` for the full glossary.

## Invariants (mandatory)

- INV-1: Do not Book the same Room twice for the same period
- INV-2: Reservation auto-expires after 30 minutes
- INV-3: Transition to Booking requires a successful card-authorization event
- INV-4: Booking check-in date ≥ Booking creation date
- INV-5: A Room before cleaning is complete cannot accept the next Booking
- INV-6: A Cancelled Booking cannot be reverted

INV violations must be detected by type errors or by property-test failures.

## Next.js 16-specific rules

- React 19-based. Use `useActionState` for Server Action form binding
  (`useFormState` is deprecated in React 19 and will be removed in the future;
  do not use in new code)
- Do not write 'use client' / 'use server' in the domain layer
  (contexts/<ctx>/domain/). Pure logic only
- Write 'use server' at the top of the file only when exporting a use case
  as a Server Action
- When importing domain types from a Client Component, only Value Objects
  are allowed. Referencing Aggregate Roots is prohibited (convert to a DTO
  before passing as props from Server Component to Client Component)
- Fetch data inside an async Server Component function by calling the
  Repository directly. Do not call your own /api endpoints via fetch
- Form submission goes through Server Actions only. Do not write client-side
  fetch for form posts
- DB access always goes through contexts/<ctx>/infrastructure/repository.ts.
  Do not call Drizzle directly from a Server Component

## OpenNext on AWS-specific rules

- Assume a Lambda runtime. Long-running tasks (>30s) must be broken into the
  EventBridge → Lambda asynchronous pattern
- Filesystem writes are forbidden (Lambda is read-only). Use /tmp for temp files only
- Read environment variables from AWS SSM Parameter Store / Secrets Manager,
  injected into Lambda by CDK
- Connections to Aurora Serverless v2 must go through a connection pool
  (via RDS Proxy)
- Image Optimization runs through OpenNext's standard Lambda, or
  CloudFront + Lambda@Edge
- Static assets: upload `next build` output to S3 and serve via CloudFront

## Coding conventions

- TypeScript strict mode required
- Domain types have already been committed under src/contexts/<ctx>/domain/.
  Always ask before changing them
- Express state transitions as discriminated unions
- If you encounter an unfamiliar domain term, ask — do not guess

Once this is in place at the repository root, the moment Role 2 asks the AI “build me the reservation request page,” the AI will reference this AGENTS.md and implement the page in line with the appropriate terminology, boundaries, and Next.js structure.

Phase 4: Commit Domain Types First

Role 4 commits the domain types in TypeScript to the repository ahead of time. This works because any code that violates the types fails to build the moment the AI generates it.

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 (pure types usable on both Client and 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 (state expressed as a 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;
    };

// === Domain logic (pure functions, callable from both Server Components and Server Actions) ===

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 (the Drizzle schema is also defined up front):

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"),
  // ...
});

By committing both the domain types and the Drizzle schema up front, we leave the AI no choice but to follow this structure during implementation. Type violations are rejected by tsc --noEmit.

Phase 5: Building the AI Prototype (Next.js Implementation)

Now, for the first time, Role 2 (the business-savvy IT lead) takes the stage. They fire up Claude Code and start working in a repository that already contains AGENTS.md and the domain types.

Here is an illustrative exchange (a plausible reconstruction, not a log from a real project):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Role 2: Please build the page where customers apply for a reservation
        from the web. After they pick a RoomType and the dates,
        a Pending Reservation should be created. Inventory check
        can be mocked.

Claude Code: Reading AGENTS.md and checking the domain types...

→ Loaded src/contexts/customer-reservation/domain/types.ts
→ Confirmed the Next.js App Router structure
→ Confirmed INV-1 through INV-6

Implementation plan:
1. app/(public)/reservations/new/page.tsx — RSC renders the form
2. app/(public)/reservations/new/actions.ts — Server Action
3. contexts/customer-reservation/use-cases/create-reservation.ts — use case (pure function)
4. contexts/customer-reservation/infrastructure/repository.ts — persistence via Drizzle
5. contexts/customer-reservation/acl/inventory-port.ts — inventory mock port
6. tests/customer-reservation/create-reservation.property.test.ts — property tests

Shall I proceed with the implementation?

Role 2 just replies “OK.” Claude Code generates the following:

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() {
  // Public page, no auth needed; rendered statically as an RSC
  return (
    <main>
      <h1>Reservation Request</h1>
      <CreateReservationForm />
    </main>
  );
}

src/app/(public)/reservations/new/form.tsx (the minimum necessary Client Component, using React 19’s 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>Room type
        <select name="roomType">
          <option value="Double">Double</option>
          <option value="Twin">Twin</option>
        </select>
      </label>
      <label>Check-in<input type="date" name="checkIn" /></label>
      <label>Check-out<input type="date" name="checkOut" /></label>
      <button type="submit" disabled={isPending}>
        {isPending ? "Processing..." : "Request reservation"}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.reservationId && <p>Tentative hold placed (please complete payment within 30 minutes)</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 (pure function, independent of 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 must be today or later
  if (input.dateRange.checkIn < input.now) {
    return { kind: "error", message: "Please pick a check-in date that is today or later" };
  }

  // Inventory check (via ACL)
  const hasInventory = await inventoryPort.checkAvailability(
    input.roomType,
    input.dateRange
  );
  if (!hasInventory) {
    return { kind: "error", message: "No rooms available for the requested period" };
  }

  // 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 };
}

Role 2 reads this code and checks whether it is reasonable from a business perspective. If the verdict is “30-minute expiry is fine, the inventory mock looks reasonable,” it’s on to the next use case. The Server Action calls the Use Case, and the Use Case calls the ACL and Repository. Domain types can be referenced from both server and client sides.

What matters here is that Role 2 has not written a single line of code. Because AGENTS.md, the types, and the Drizzle schema have been laid out correctly in advance, the AI generates an appropriate implementation that follows the Next.js structure. Role 2’s job is to judge only one thing: “is this right for the business?

Over a few days, the main use cases come together: reservation request, card-authorization Webhook (Route Handler), automatic cancellation on expiry (AWS EventBridge → a dedicated Lambda, triggered every 5 minutes), and user-initiated cancellation (Server Action). The system reaches a state usable for an internal demo.

Phase 6: Putting Guardrail CI in Place

Role 4 configures guardrail CI in GitHub Actions.

.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
69
name: Guardrails CI

on: [push, pull_request]

jobs:
  ubiquitous-language:
    name: Ubiquitous language taboo check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check forbidden synonyms
        run: |
          # A Booking should never be in a Pending state (→ Reservation)
          if grep -rE "Booking.*['\"]Pending['\"]" src/ ; then
            echo "ERROR: Booking has no Pending state"
            exit 1
          fi
          # Conflating Room with RoomType
          if grep -rE "Room\b.*=.*['\"](Single|Double|Twin|Suite)['\"]" src/ ; then
            echo "ERROR: Assigning a RoomType value to Room (individual guest room)"
            exit 1
          fi

  bounded-context:
    name: Bounded Context boundary check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check cross-context imports
        run: |
          # Direct imports from contexts/<a>/ to contexts/<b>/ are only
          # allowed via the ACL layer
          ! 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 boundary check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check 'use client' / 'use server' misuse
        run: |
          # The domain layer must not contain 'use client' / 'use server'
          if grep -rE "^['\"]use (client|server)['\"]" src/contexts/*/domain/ ; then
            echo "ERROR: domain layer contains 'use client' / 'use server'"
            exit 1
          fi
          # Server Components must not fetch their own /api endpoints
          if grep -rE "fetch\(['\"]\/api\/" src/app/ ; then
            echo "ERROR: a Server Component is fetching its own /api endpoint"
            exit 1
          fi

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

  property-tests:
    name: Property tests for invariants
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:property

With this in place, CI fails the moment the AI paraphrases terminology, jumps across a boundary, or violates the Next.js structure. Even if Role 2 tries to merge based only on “it works,” CI stops the merge. The safety net does not rely on Role 2’s judgment.

Phase 7: Handoff Decisions by the Production Engineer

After three months of beta operation, Role 3 (two production engineers) takes over. The prototype builder hands them:

  1. The full repository (code + AGENTS.md + types + Drizzle schema + tests + CI)
  2. The domain-definition documents (ubiquitous language, Bounded Contexts, invariants, use-case specs)
  3. Notes on domain knowledge discovered during beta operation
  4. A list of known constraints (e.g., “accounting-system integration is currently a manual copy-paste workflow”)

Role 3 makes a discard/keep decision for each part of the Next.js structure:

PartDecisionReason
contexts/*/domain/types.ts (domain types)Keep as-isWritten under TypeScript strict, passes property tests, state transitions expressed as discriminated unions
contexts/*/use-cases/*.ts (use cases)Mostly keepWritten as pure functions, independent of Next.js / AWS. Reinforce error handling and logging for production
contexts/*/infrastructure/repository.ts (Drizzle)Mostly keepThe Drizzle schema was defined up front and is consistent with the production Aurora DB. Switch to a connection pool via RDS Proxy
contexts/*/acl/*.ts (ACL)Rewrite the mock parts for productionMocked during prototyping; integrate with real systems for production (including the accounting system)
app/(public)/** (customer-facing pages)Replace the design; keep the logicThe prototype UI is for validation; replace with the production designer’s design. Server Actions stay
app/(admin)/** (front-desk staff pages)Mostly keepFront-desk operability has been validated during beta operation
app/api/webhooks/** (Route Handlers)Extend and keepAdd signature verification, idempotency, retry handling. Rate-limit via CloudFront
Scheduled Lambdas (expiry processing, etc.)Extend and keepAdd EventBridge schedules, DLQ, and alerts
Authentication (Auth.js config)Strengthen for productionAdd OAuth providers on the customer side; AWS Cognito + MFA on the staff side
OpenNext / CDK configurationProductionizePrototype used a single environment; production needs dev/staging/prod separation, Aurora read replicas, and a serious CloudFront caching strategy
Monitoring / loggingNew implementationCloudWatch Logs + X-Ray + Sentry + OpenTelemetry
CI/CDExtend and keepOn top of the existing guardrails, add CDK deploys, blue/green switching, and rollback

The result is that the domain types, use-case layer, Drizzle schema, and Server Action structure are carried into production untouched. The parts that need rewriting (stronger auth, UI design, ACL implementations, operational infrastructure) are separated along the Next.js structure, so it is clear where to rewrite.

The documents — especially AGENTS.md — continue to be used during production development. Production engineers also use AI assistance, so by carrying AGENTS.md forward we give the AI the same domain knowledge and Next.js rules, and the generated code stays consistent.

Retrospective: Time and Deliverables

PhaseDurationRoles involved
1. Event Storming sessions3 daysRole 1 + 7 business stakeholders
2. Organizing the five elements1 weekRole 1
3. Writing AGENTS.md2 daysRole 4
4. Pre-committing domain types + Drizzle schema3 daysRole 4
5. AI prototyping (Next.js implementation)2–3 weeksRole 2 (alone)
6. Guardrail CI setup1 dayRole 4 (in parallel with Phase 5)
7. Beta operation + feeding discoveries back1 monthRole 2 + business stakeholders
8. Productionization2–3 monthsRole 3

Roughly six months from design to production operation (a hypothetical figure for this scenario; phases overlap and rework is built in). By experience, a system of similar size run through the traditional waterfall (requirements → high-level design → detailed design → implementation → test) often takes 8–12 months. That said, this is rule-of-thumb and varies considerably with project size, team composition, and the presence of existing assets — a caveat worth noting.

The biggest efficiency gain is that Role 2 can carry Phase 5 (prototyping) alone. Because Role 4 has already set up AGENTS.md, the types, and the Drizzle schema, a non-engineer in Role 2 can implement the main use cases in 2–3 weeks while following the Next.js structure.

Another efficiency gain is that handoff in Phase 7 happens early. The domain types, use cases, Drizzle schema, and Server Action structure scale straight into production, so Role 3 doesn’t have to rewrite from scratch. The handoff decision itself stays simple — listing “keep this, rewrite that” at the part level.

Did the Pitfalls Actually Happen?

The four pitfalls specific to this approach, raised in the design-theory companion, can each be reconstructed as “a pattern that could realistically arise” in this scenario (it’s unlikely that all four would hit one real project simultaneously, but each is plausible):

Pitfall 1 (definition quality propagates to results): Phase 2 forgot to define the business rule for “cancellation fees.” The Phase 5 prototype was therefore implemented as “free cancellation,” and during beta operation the owner noticed “this hurts revenue.” We circled back to Phase 2 and added the rule.

Pitfall 2 (feeding back discoveries): During beta operation, the business knowledge that “group reservations follow a different flow from regular reservations” surfaced. At first no one had been aware of the distinction. Role 2, who noticed it, updated AGENTS.md and the Bounded Context map (adding a new Group Reservation context) and added an src/contexts/group-reservation/ directory.

Pitfall 3 (freezing boundaries too early): Phase 1 split “Front Desk” and “Housekeeping” into separate contexts, but beta operation revealed that “some small branches actually have the same team doing both.” The boundary was redrawn by responsibility rather than by organization (kept inside Front Desk, but unified only the cleaning-notification path).

Pitfall 4 (over-definition): Role 1 initially insisted, “I want to discover everything perfectly through Event Storming before handing it off,” but in Phase 2 we judged that if we did not put a time box on this, prototyping would never start. We treated the Customer Reservation and Inventory contexts as the “minimum viable launch set” and integrated Front Desk and Housekeeping after beta operation.

Each of these pitfalls had a framework for handling them, so none became fatal. That is the practical payoff of “making it work through roles and scaffolding” introduced in the design-theory companion.

Wrap-Up

We walked through a scenario that implements a hotel reservation system in Next.js 16 + TypeScript + Drizzle and deploys it to AWS via OpenNext, to see DDD-first AI prototyping in action.

The skeleton from the design-theory companion — 5 elements, 4 patterns, 3+1 roles, scaffolding, pitfalls — maps cleanly onto concrete phases and Next.js structures:

  • 5 elements → the Phase 2 deliverables
  • Of the 4 patterns, Pattern 2 (AGENTS.md) and Pattern 3 (domain types + Drizzle schema) → Phases 3 and 4
  • 3+1 roles → who runs each phase
  • Scaffolding → Phases 3, 4, and 6 (AGENTS.md, types, CI)
  • Pitfalls → arose and were handled during beta operation

The biggest feature of this workflow is that Role 2 could drive Phase 5 alone even though they are not an engineer. What makes that work is the scaffolding Role 4 set up in Phases 3, 4, and 6: AGENTS.md (including Next.js-specific rules), the domain types + Drizzle schema, and guardrail CI.

The payoff of Next.js 16 + OpenNext on AWS is clear: the Server/Client boundary, Server Actions, Route Handlers, and a Drizzle schema — an ordinary production-grade stack — scale straight into production on AWS without locking you into Vercel. In Phase 7 the domain types, use cases, and Drizzle schema move into production almost untouched. The benefit of “prototype on production technology” lands concretely, via the Next.js structure and the AWS operational foundation.

Being able to move back and forth between design theory and practice lets you judge how to adapt this to your own project. For example: “we don’t have anyone who can hold both Role 1 and Role 4,” or “we only have two Bounded Contexts, so Phase 1 can be much shorter,” or “our hosting target is GCP / Cloudflare rather than AWS, but if we swap OpenNext for @cloudflare/next-on-pages, the structure is the same,” or “we’re not on Next.js but on Spring Boot, but AGENTS.md and the Aggregate structure are the same” — you can shape the workflow to your circumstances.

That is the goal of this workflow: a concrete prescription for making projects succeed by riding AI-era fast coding.

If this topic interests you, see also:

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