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
      
No new runtime machinery. Modules are folders; the existing filesystem router, island scanner, and agent scan all gain subdirectory awareness.

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 pathURLNote
modules/home/routes/index.tsx/Root of home module
modules/home/routes/about.tsx/aboutNo module prefix in URL
modules/auth/routes/auth/login.tsx/auth/loginExplicit namespace subdirectory
modules/api/routes/api/health.ts/api/healthAPI 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 pathapp/modules/auth/islands/LoginForm.tsx
  • Registry keyauth/LoginForm
  • Usage in route<Island name="auth/LoginForm" />
  • Framework changesrc/build/scan-islands.ts — strip app/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.

SubdirectoryPurposeScanned by
routes/Route handlers and page componentsRouter (build time)
islands/Client-side React componentsIsland scanner (build time)
agents/defineAgent() exportsAI scan (build time)
tools/defineTool() exportsAI scan (build time)
jobs/defineJob() exportsAI scan (build time)
_lib/Module-private logic — any name starting with _ worksNot 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;
      
If dashboard needs auth session data, move the relevant logic to _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 },
})
  • Typestring[] — one or more module root directories
  • Default[] — flat layout is unchanged when absent
  • Router behaviourGlobs <modulesDir>/* to discover module roots; scans routes/ 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"] to patties.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/ and app/islands/ when migration is complete.

Acceptance criteria

  • A project with modulesDirs: ["app/modules"] runs patties dev and correctly serves routes from all module routes/ subtrees.
  • Islands under app/modules/<mod>/islands/ register with keys <mod>/<ComponentName> and hydrate correctly when referenced as <Island name="<mod>/<ComponentName>">.
  • patties build integration tests pass for both flat and modular layouts.
  • Route conflict detection spans module boundaries.
  • The agents-md manifest groups by module when modulesDirs is set.
  • A project with no modulesDirs behaves exactly as today — zero regression.