Spec 28 — Modular Monolith Convention
The canonical app shape for Patties: a single deployable Bun app organised by domain module first, type second. One Bun.serve, one build, one patties.config.ts. Modules are colocation units — not deployment units.
Status: Draft
Depends on CLI Spec 21 for the scaffold changes. Framework changes needed: modulesDirs config key, island scanner key generation, agents-md grouping.
Purpose & motivation
Today's flat layout works for tutorials; it doesn't scale for products.
Today — type-first flat layout
All routes in app/routes/, all islands in app/islands/, all agents in app/agents/. Every domain crosses every top-level directory. Adding a feature scatters files across the tree.
Spec 28 — domain-first modular monolith
Each domain has one home: app/modules/<domain>/. Routes, islands, agents, tools, and jobs live together. Adding a feature is adding a folder.
flowchart LR
subgraph TODAY["Type-first (today)"]
R["app/routes/auth/login.tsx"]
I["app/islands/LoginForm.tsx"]
A["app/agents/auth.ts"]
end
subgraph SPEC28["Domain-first (spec 28)"]
direction TB
M["app/modules/auth/"]
MR[" routes/auth/login.tsx"]
MI[" islands/LoginForm.tsx"]
MA[" agents/auth.ts"]
ML[" _lib/session.ts"]
M --> MR
M --> MI
M --> MA
M --> ML
end
TODAY -. migrate .-> SPEC28
Canonical directory layout
app/
├── modules/
│ ├── home/ # landing / marketing module
│ │ ├── routes/
│ │ │ ├── index.tsx # GET /
│ │ │ └── about.tsx # GET /about
│ │ └── islands/
│ │ └── HeroSection.tsx # client island for this module
│ ├── auth/ # authentication module
│ │ ├── routes/
│ │ │ ├── login.tsx # GET/POST /auth/login
│ │ │ └── signup.tsx # GET/POST /auth/signup
│ │ ├── islands/
│ │ │ └── LoginForm.tsx
│ │ └── _lib/ # module-private (underscore = not scanned)
│ │ └── session.ts
│ ├── api/ # API-only module (no islands)
│ │ └── routes/
│ │ └── api/
│ │ └── health.ts # GET /api/health
│ └── ai/ # optional AI module
│ ├── agents/
│ │ └── concierge.ts
│ └── tools/
│ └── search.ts
├── _shared/ # cross-module shared code
│ ├── db.ts
│ └── types.ts
└── middleware.ts # global entry
URL mapping rule
The modules/ and <mod>/routes/ prefix segments are stripped; only the path below routes/ becomes the URL. The module name is not inferred as a URL prefix — the developer declares it explicitly.
| File path | URL | Note |
|---|---|---|
modules/home/routes/index.tsx | / | Root of home module |
modules/home/routes/about.tsx | /about | No module prefix in URL |
modules/auth/routes/auth/login.tsx | /auth/login | Explicit namespace subdirectory |
modules/api/routes/api/health.ts | /api/health | API module with explicit prefix |
Convention
If a module owns a URL namespace, its route files sit in a same-name subdirectory (modules/auth/routes/auth/). The URL is a contract — don't hide the namespace in the filesystem path.
Island naming in subdirectories
The island scanner must produce unique, collision-free registry keys. Two modules can each define a LoginForm island; the key must include the module name.
- File path
app/modules/auth/islands/LoginForm.tsx - Registry key
auth/LoginForm - Usage in route
<Island name="auth/LoginForm" /> - Framework change
src/build/scan-islands.ts— stripapp/modules/<mod>/islands/as root, emit<mod>/<ComponentName>. The<Island name="…">API already accepts any string; only scanner + registry generation change.
// modules/home/routes/index.tsx
import { Island } from "patties/render"
export default function Home() {
return (
<main>
<Island name="home/HeroSection" /> {/* ← module-prefixed key */}
<Island name="auth/LoginForm" /> {/* ← sibling module island */}
</main>
)
}
Module anatomy
A module is a directory under app/modules/. Every subdirectory has a defined role. Underscore-prefixed dirs are skipped by all scanners.
| Subdirectory | Purpose | Scanned by |
|---|---|---|
routes/ | Route handlers and page components | Router (build time) |
islands/ | Client-side React components | Island scanner (build time) |
agents/ | defineAgent() exports | AI scan (build time) |
tools/ | defineTool() exports | AI scan (build time) |
jobs/ | defineJob() exports | AI scan (build time) |
_lib/ | Module-private logic — any name starting with _ works | Not scanned |
Cross-module dependency rule
The only legitimate cross-module import is through app/_shared/. No module barrels, no re-exports to siblings.
✓ Allowed
// modules/dashboard/routes/dashboard/index.tsx
import { db } from "../../_shared/db"
import { User } from "../../_shared/types"
✗ Not allowed
// modules/dashboard/routes/dashboard/index.tsx
// reaching into a sibling's private lib
import { sessionStore } from "../auth/_lib/session"
Warning
There is no index.ts barrel per module — modules are not packages; they do not export to each other. If module A needs something from module B, that thing belongs in _shared/. This keeps coupling visible and intentional.
flowchart TB
subgraph AUTH["modules/auth/"]
AS["_lib/session.ts (private)"]
end
subgraph DASH["modules/dashboard/"]
DR["routes/dashboard/index.tsx"]
end
subgraph SHARED["_shared/"]
DB["db.ts"]
UT["types.ts"]
end
DR -->|"✅ import ../../_shared/db"| DB
DR -. "❌ import ../auth/_lib/session" .-x AS
classDef bad fill:#fee2e2,stroke:#ef4444,color:#7f1d1d;
class AS bad;
_shared/ — never reach across module boundaries.modulesDirs config key
One new optional field in patties.config.ts. The flat layout remains the default; both layouts coexist during migration.
// patties.config.ts
import { defineConfig } from "patties/config"
export default defineConfig({
target: "bun",
modulesDirs: ["app/modules"], // ← new field; default: []
server: { port: 3000 },
})
- Type
string[]— one or more module root directories - Default
[]— flat layout is unchanged when absent - Router behaviourGlobs
<modulesDir>/*to discover module roots; scansroutes/per root, stripping<mod>/routes/as base - Island scannerScans
islands/per module root; key prefix =<mod>/ - AI scanScans
agents/,tools/,jobs/per module root - CoexistenceFlat
appDir/routes/still scanned alongside module routes; route conflict detection spans both
Dropped concepts
ProjectType removed
frontend / backend / fullstack scaffold choice is gone. Every Patties app is full-stack. A developer who wants API-only routes adds an api/ module with no islands. See CLI Spec 21.
Flat default template retired
The scaffold's templates/default/app/routes/ flat structure is replaced by the modular monolith layout. The _backend/ overlay template is deleted.
agents-md manifest grouped by module
When modulesDirs is set, the generated CLAUDE.md groups routes, islands, agents, tools, and jobs by module — the LLM context mirrors the developer's mental model.
<!-- patties:manifest-start -->
## Routes
### home
| Pattern | File |
|---|---|
| GET / | modules/home/routes/index.tsx |
| GET /about | modules/home/routes/about.tsx |
### auth
| Pattern | File |
|---|---|
| GET /auth/login | modules/auth/routes/auth/login.tsx |
| POST /auth/login | modules/auth/routes/auth/login.tsx |
### api
| Pattern | File |
|---|---|
| GET /api/health | modules/api/routes/api/health.ts |
<!-- patties:manifest-end -->
LLM rules overlay
Ships as .claude/rules/patties-modular-monolith.md in scaffolded projects via the _claude/ agent overlay in create-patties.
# Patties modular monolith rules
This is a single deployable Bun app. Features are domain modules under
`app/modules/`. One `Bun.serve`, one build, one `patties.config.ts`.
Modules are colocation units, not deployment units.
**Adding a feature = adding a module.**
Create `app/modules/<domain>/` with routes/, islands/, etc.
Never scatter a feature across the root-level directories.
**Cross-module code lives in `app/_shared/`.**
If two modules share logic, move it to `_shared/`. Never import from a
sibling module's `_lib/`. If you find `../auth/_lib/session`, move it.
**Islands are keyed by module.**
`app/modules/auth/islands/LoginForm.tsx` → `<Island name="auth/LoginForm">`.
Always use the `<module>/<Component>` key, not a bare component name.
**API routes are a module, not a separate backend.**
`app/modules/api/routes/` holds JSON handlers. There is no separate app.
The same Bun process handles pages and API calls.
**Never split into a separate repo for a feature.**
A Patties modular monolith handles one product domain. Add modules.
**Register the modules root in patties.config.ts.**
export default defineConfig({ modulesDirs: ["app/modules"] })
Migration path (flat → modular)
Flat and modular layouts coexist during migration. Move one domain at a time.
- Add
modulesDirs: ["app/modules"]topatties.config.ts. - Create
app/modules/<domain>/routes/and move relevant route files. - Create
app/modules/<domain>/islands/and move islands — update all<Island name="…">to use the<domain>/prefix. - Move module-private logic into
app/modules/<domain>/_lib/. - Move shared logic into
app/_shared/. - Delete empty
app/routes/andapp/islands/when migration is complete.
Acceptance criteria
- A project with
modulesDirs: ["app/modules"]runspatties devand correctly serves routes from all moduleroutes/subtrees. - Islands under
app/modules/<mod>/islands/register with keys<mod>/<ComponentName>and hydrate correctly when referenced as<Island name="<mod>/<ComponentName>">. patties buildintegration tests pass for both flat and modular layouts.- Route conflict detection spans module boundaries.
- The agents-md manifest groups by module when
modulesDirsis set. - A project with no
modulesDirsbehaves exactly as today — zero regression.