A Thought Experiment with a Hotel Reservation System: Implementing DDD-First AI Prototyping in Next.js 16 + OpenNext
This article was generated by AI. The accuracy of the content is not guaranteed, and we accept no responsibility for any damages resulting from use of this article. By continuing to read, you agree to the Terms of Use.
- 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.”
| Item | Description |
|---|---|
| Locations | 5 branches, 15–30 rooms each |
| Current state | Reservations are managed by phone, fax, and paper guest ledgers |
| Pain points | No web reservations, losing overseas customers; several double-bookings per month |
| Goal | Beta version (internal validation) in 3 months, production in 6 months |
| Constraints | Integration with the existing accounting system (on-prem) is mandatory |
| Tech stack | Next.js 16 (App Router, React 19) / TypeScript strict / Drizzle ORM |
| Hosting | OpenNext on AWS (Lambda + CloudFront + S3 + Aurora Serverless v2) |
| Authentication | Auth.js (OIDC; AWS Cognito integration considered for production) |
Role assignments:
| Role | Person |
|---|---|
| Role 1: Domain definer | Front-desk general manager with 15 years on the job + an external DDD consultant |
| Role 2: Prototype builder | Minato’s IT lead (a former front-desk manager who knows the business and can read code) |
| Role 3: Production engineer | Two senior engineers from an external vendor |
| Role 4: AI environment engineer | The 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:
- The full repository (code + AGENTS.md + types + Drizzle schema + tests + CI)
- The domain-definition documents (ubiquitous language, Bounded Contexts, invariants, use-case specs)
- Notes on domain knowledge discovered during beta operation
- 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:
| Part | Decision | Reason |
|---|---|---|
contexts/*/domain/types.ts (domain types) | Keep as-is | Written under TypeScript strict, passes property tests, state transitions expressed as discriminated unions |
contexts/*/use-cases/*.ts (use cases) | Mostly keep | Written as pure functions, independent of Next.js / AWS. Reinforce error handling and logging for production |
contexts/*/infrastructure/repository.ts (Drizzle) | Mostly keep | The 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 production | Mocked during prototyping; integrate with real systems for production (including the accounting system) |
app/(public)/** (customer-facing pages) | Replace the design; keep the logic | The prototype UI is for validation; replace with the production designer’s design. Server Actions stay |
app/(admin)/** (front-desk staff pages) | Mostly keep | Front-desk operability has been validated during beta operation |
app/api/webhooks/** (Route Handlers) | Extend and keep | Add signature verification, idempotency, retry handling. Rate-limit via CloudFront |
| Scheduled Lambdas (expiry processing, etc.) | Extend and keep | Add EventBridge schedules, DLQ, and alerts |
| Authentication (Auth.js config) | Strengthen for production | Add OAuth providers on the customer side; AWS Cognito + MFA on the staff side |
| OpenNext / CDK configuration | Productionize | Prototype used a single environment; production needs dev/staging/prod separation, Aurora read replicas, and a serious CloudFront caching strategy |
| Monitoring / logging | New implementation | CloudWatch Logs + X-Ray + Sentry + OpenTelemetry |
| CI/CD | Extend and keep | On 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
| Phase | Duration | Roles involved |
|---|---|---|
| 1. Event Storming sessions | 3 days | Role 1 + 7 business stakeholders |
| 2. Organizing the five elements | 1 week | Role 1 |
| 3. Writing AGENTS.md | 2 days | Role 4 |
| 4. Pre-committing domain types + Drizzle schema | 3 days | Role 4 |
| 5. AI prototyping (Next.js implementation) | 2–3 weeks | Role 2 (alone) |
| 6. Guardrail CI setup | 1 day | Role 4 (in parallel with Phase 5) |
| 7. Beta operation + feeding discoveries back | 1 month | Role 2 + business stakeholders |
| 8. Productionization | 2–3 months | Role 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.
Related Articles
If this topic interests you, see also:
- DDD-First AI Prototyping: Making It Work Through Roles and Scaffolding — The design-theory companion. We recommend reading it alongside this article
- How to Combine VSA and DDD in AI Development — Same series, strategic discussion
- A Three-Layer Integration of VSA × DDD × Harness — Same series; resolving the “split it” vs. “share it” contradiction at the boundary
- A Practical Vibe Coding Guide for Junior Engineers — AI prototyping at the individual level
- Simple Is the Fastest Way to Build — Prevention strategy at the design level