Features
Everything else is Bun and the web platform. The framework's own backend surface is exactly three features: a module convention with a public-API boundary, an explicit data-ownership model for the few shared structures, and a dependency-graph checker that keeps the module graph a DAG. A fourth section covers the frontend / UI stack — React 19 + patties-ui and the layers a modular monolith needs above it.
Modules & the public-API boundary
A module is a folder with a public index.ts. Other modules may import that, and nothing deeper — the build enforces it. There is no DI container and no bespoke request lifecycle: wiring is a plain import, and a handler runs inside the compose() middleware patties already ships.
Module anatomy
Convention over configuration. The folder name is the module name; index.ts is its contract; routes.ts is its HTTP surface; everything else is private.
The two named files
index.ts— the public API. Re-exports the values/types other modules may use. The only legal import target.routes.ts— exports aroutesmap inBun.serveshape. Collected and merged at build.
Everything else is private
- Services, schemas, helpers, db access — plain files, plain exports.
- Used freely within the module; invisible across modules unless re-exported from
index.ts.
// app/billing/index.ts — the front door
export { invoiceService } from "./invoice.service";
export type { Invoice } from "./invoice.service";
// note: ledger.ts is NOT re-exported → it stays private to billing/
The boundary check — the one new rule
This is the entire "framework enforcement." A static read of import specifiers at build time: an import that resolves inside another module's folder but isn't its index.ts is a build error.
// app/orders/order.service.ts
import { invoiceService } from "../billing"; // ✅ public index
import { ledger } from "../billing/ledger"; // ❌ build error
// ↳ orders/order.service.ts:2 — deep import into module 'billing'.
// Reach it through ../billing (its public index.ts) or export it there.
// within the SAME module, anything goes:
import { ledger } from "./ledger"; // ✅ same module
flowchart LR
I["import specifier"] --> Q{"resolves inside
another module's folder?"}
Q -->|no| OK1["✅ allow
(core, npm, same module)"]
Q -->|yes| Q2{"is it that module's
index.ts?"}
Q2 -->|yes| OK2["✅ allow"]
Q2 -->|no| BAD["❌ build error
+ file:line + fix hint"]
classDef bad fill:#fee2e2,stroke:#ef4444,color:#7f1d1d;
class BAD bad;
Wiring & lifetimes — without a container
"Dependency injection" in this design is just ES modules. A service is a module-level value; importing it gives you the one instance Bun caches. That covers the singleton case — which is nearly all of it.
- Singleton
- Default, free
export const invoiceService = new InvoiceService(db)— module-cached, instantiated once on first import. - Per-request
- Plain code Construct it in the handler, or read it off the thin
ctx. No request-scope machinery in the framework. - Configurable / swappable
- Factory export Export a
createX(opts)factory; the composition root (or a test) calls it. Visible in the types, not hidden in a container. - Testing
- Standard tools Swap with
bun testmodule mocking, or pass fakes into the factory. No DI overrides to learn.
Cycles are forbidden
Cycles between modules are forbidden, not just inconvenient. Because wiring is plain imports, an inter-module cycle compiles silently — but it means you can't understand, test, or extract any module in the cycle without the others. See the graph checker for the enforcement rule.
Acyclic graph — no spaghetti paths
A→B is fine. A→B→C is fine. A→B→C→A is spaghetti: every module in the chain is now load-bearing for all the others. The rule is simple: the module dependency graph must be a DAG.
flowchart LR
subgraph OK["✅ Allowed — directed acyclic"]
A1["users"] --> B1["platform"]
orders1["orders"] --> B1
B1 --> C1["config"]
end
subgraph BAD["❌ Forbidden — cycle"]
A2["orders"] --> B2["billing"]
B2 --> C2["notifications"]
C2 -->|"❌ closes the cycle"| A2
end
classDef bad fill:#fee2e2,stroke:#ef4444,color:#7f1d1d,stroke-width:2px;
class BAD,C2 bad;
patties graph reports the full chain with the closing edge highlighted.Request flow is the existing middleware
There are no guards, pipes, interceptors, or filters to map. A request flows through patties' compose() chain to the matched module handler and back. Cross-cutting concerns are middleware — exactly as today.
flowchart LR
REQ(["Request"]) --> MW["compose() middleware
auth · logging · whatever you add"]
MW --> H["module handler
(req, ctx) => Response"]
H --> MW2["middleware unwinds
(timing, headers, errors)"]
MW2 --> RES(["Response"])
classDef h fill:#1d4ed8,color:#fff,stroke:#1e3a8a,stroke-width:2px;
class H h;
Shared infrastructure & shutdown
Things many modules need — a db pool, a cron scheduler — live in a small shared module (e.g. app/_platform/) that exposes them through its own public index.ts. The same boundary rule applies.
// app/_platform/index.ts
export { db } from "./db"; // bun:sqlite / Bun.SQL — one pool, imported everywhere
export { scheduler } from "./cron"; // patties' existing job primitives
// app/billing/invoice.service.ts
import { db } from "../_platform"; // ✅ public API of the platform module
Graceful shutdown is whatever the platform module registers on Bun's signal handling — not a framework-managed onDestroy ordering. Init order is module import order, which Bun already determines deterministically.
Data ownership — who owns what, and when
Shared mutable state is the primary way module boundaries erode over time. This feature assigns explicit ownership for every shared data structure in the framework, defines mutation windows, and shows which modules may read vs. write each structure.
The ownership model
One module creates a structure, freezes it at the end of its mutation window, and hands it to consumers as read-only. No other module mutates it after that. Swapping is always replace-the-reference, never mutate-in-place.
Creator — writes
One module is responsible for constructing and populating a shared structure. No other module writes to it during this phase.
Freeze point
At the end of the creation phase the structure is frozen (Object.freeze). Any subsequent mutation attempt throws in strict mode.
Consumers — read only
All other modules receive the frozen value. They may read any property; they must not cast away Readonly<> or call Object.assign on it.
Swap, don't mutate
When a structure must change (e.g. dev island rebuild), the owning module creates a new instance and replaces the reference at the server level atomically. The old frozen object is garbage-collected; consuming modules never see a partially-updated state.
ClientManifest
Maps island names to their public URLs. Created once per build; swapped (not mutated) on dev island rebuild.
flowchart LR
B["build/\ncreates + freezes"] -->|frozen ClientManifest| S["server/\nholds reference"]
S -->|read-only pass-through| R["render/\nreads per request"]
D["dev/\nrebuild completes"] -->|new frozen instance| S
P["plugin onBuildEnd\nhook"] -->|may add assets BEFORE freeze| B
classDef owner fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:2px;
classDef consumer fill:#f0fdf4,stroke:#16a34a,color:#14532d;
classDef swap fill:#fef9c3,stroke:#ca8a04,color:#713f12;
class B owner;
class R,S consumer;
class D swap;
- Owner
build/- Mutation window
- Inside
build(), and during theonBuildEndplugin hook (beforeObject.freezeis called) - Freeze point
build()callsObject.freeze(manifest.islands); Object.freeze(manifest)before returning- Consumers
server/(holds reference),render/(reads per-request — never caches beyond one render call)- Dev swap
dev/creates a newClientManifestafter each island rebuild and atomically replaces the pointer held byserver/. It never calls any method on the old manifest.
// src/build/index.ts — the only place ClientManifest is created
const manifest: ClientManifest = { entry: "...", islands: { ... } }
Object.freeze(manifest.islands)
Object.freeze(manifest)
return { clientManifest: manifest, ... }
// src/dev/bundler.ts — swap, never mutate
const next = buildIslandManifest(rebuiltChunks) // new frozen instance
server.swapManifest(next) // replace reference atomically
Route table
Scanned at build time via Bun macro, inlined into the server bundle, then frozen. The production server never re-scans the filesystem. Dev mode re-scans on file change and replaces the active router.
flowchart LR
subgraph BUILD["Build time"]
FS["router/filesystem\nscanRoutes() — Bun macro"]
GEN["build/\ngenerates server entry with\ninlined route literal"]
end
subgraph BOOT["Server boot"]
CR["router/\ncompileRouter(inlinedRoutes)\n→ CompiledRouter (frozen)"]
SRV["server/ + Bun.serve"]
end
subgraph DEV["Dev mode only"]
RESCAN["dev/\nfile change → re-scanRoutes()\n→ new CompiledRouter"]
end
FS --> GEN --> CR --> SRV
RESCAN -->|replace reference| SRV
classDef owner fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a,stroke-width:2px;
class FS,GEN owner;
Bun.Glob at runtime. See the build-time-discovery rule.- Owner
build/(build time) →server/(runtime, read-only)- Mutation window
- Build-time macro only. Plugin routes (
server.route()) are folded in during the boot phase, before the first request. - Freeze point
router/compileRouter()returns a frozenCompiledRouter. No route may be added or removed after boot.- Dev swap
dev/is the only module permitted to callscanRoutes(). On change it creates a newCompiledRouterand replaces the active one.
Agent & tool registries
Scanned once at boot (or build time via macro). Frozen immediately after the scan. getAgent() and getTool() are the only public accessors — they return read-only values.
- Owner
ai/—AgentRegistryandToolRegistrysingletons- Mutation window
- During
ai/scan.tsboot scan only. The registries are populated fromapp/agents/andapp/tools/before the first request is served. - Freeze point
- After scan completes. No post-boot registration.
- Duplicate names
- Boot error Two agents or tools with the same name cause an immediate process exit with both conflicting file paths in the message. First-one-wins is not acceptable.
- Consumers
getAgent()/getTool()returnReadonly<AgentDefinition>/Readonly<ToolDefinition>. No module outsideai/may hold a direct registry reference.- Plugin hook
onJobsCollectreceives a read-onlyJobSummary[]snapshot. It may not add jobs to the registry.
Request-scoped state
PattiesContext and AiContext are created fresh per request and destroyed when the response is returned. They must never be stored outside the synchronous handler chain.
PattiesContext
- Created by
server/at the start of each request. ctx.varsis the only mutable slot — for middleware to pass typed values downstream within the same request, not across requests.ctx.json()/ctx.html()/ctx.redirect()are one-shot — calling them twice is a runtime error.- Must not be stored in a module-level variable. Referencing it outside the handler chain is a boundary violation.
AiContext
- Created by
ai/per AI-enabled request, wrappingPattiesContext. - Conversation history is scoped to this context — it must not be stored in a module-level variable.
- Destroyed when the streaming response completes.
- Never passed to middleware. Middleware receives only
PattiesContext. - Persistence (session-backed history) is a user concern via a storage adapter, not a framework concern.
Don't close over context
Storing a PattiesContext or AiContext in a variable outside the handler (e.g., for logging or caching) leaks request state across requests and breaks the ownership model. Extract the data you need (ctx.params.id, a header value) — not the context object itself.
Mutation windows at a glance
Every shared structure has exactly one owner, one mutation window, and one freeze point. After the freeze, any write throws.
| Structure | Owner | Mutation window | Frozen by | Consumers |
|---|---|---|---|---|
ClientManifest |
build/ |
Inside build() + onBuildEnd hook |
build() before return |
server/, render/ (read-only) |
Route table (RouteEntry[]) |
build/ → server/ |
Build-time macro + plugin setup() during boot |
router/compileRouter() |
server/ (read-only at runtime) |
AgentRegistry |
ai/ |
Boot scan only | After scan completes | ai/getAgent() (read-only) |
ToolRegistry |
ai/ |
Boot scan only | After scan completes | ai/getTool() (read-only) |
PattiesContext |
Request handler | Single request cycle (ctx.vars only) |
Response returned | Middleware chain (read + limited write to vars) |
AiContext |
ai/ |
Single request cycle | Response stream ends | ai/ only |
Note
The dependency graph checker (patties graph --strict) detects module-level variables of type PattiesContext or AiContext declared outside src/server/ and src/ai/ respectively, and reports them as ownership violations.
patties graph — no cycles, no spaghetti
A static import-graph analyser that enforces two invariants: every cross-module import goes through a public index.ts, and the module dependency graph is a DAG — no A → B → C → A. Build errors catch the obvious cases; this command catches the subtle ones and acts as the CI gate.
Why a dedicated checker
The build-time boundary check (in the route-merge macro) catches deep imports. But it runs only during patties build, only for module roots, and only for cross-module paths it can resolve at that point. A cycle spanning three modules, a private import inside a test helper, or a deep import introduced via a re-export barrel — these all need a second pass.
Similar to bun typecheck
Runs outside the build pipeline — no bundle produced. Reports structural issues in the source tree. Fast enough to run on every push.
Not a linter
Biome owns style and code-quality rules. patties graph only knows about module boundaries and import direction. No overlap.
Works on user apps too
Runs on app/modules/ by default. Use --framework to check the framework source tree itself (useful when contributing to patties).
Bun-native
Import extraction via Bun.Transpiler. Module resolution via Bun.resolve(). Graph walk in plain TypeScript. No extra deps.
The acyclic rule — A → B, never A → B → C → A
A direct dependency is fine. A transitive dependency is fine. A dependency that loops back — however many hops away — is not.
flowchart LR
subgraph OK["✅ Allowed — acyclic"]
A1["users"] --> B1["platform"]
B1 --> C1["config"]
end
subgraph BAD["❌ Forbidden — cycle detected"]
A2["orders"] --> B2["billing"]
B2 --> C2["notifications"]
C2 -->|"depends on orders"| A2
end
classDef bad fill:#fee2e2,stroke:#ef4444,color:#7f1d1d,stroke-width:2px;
class BAD bad;
class C2 bad;
orders → billing → notifications → orders. Each hop may be individually reasonable, but the chain as a whole creates spaghetti — you can't understand, test, or extract any of these modules without the others.The one rule
Module A may depend on module B. Module B may depend on module C. Module C must not depend on module A (or on anything that transitively depends on A). The dependency graph is a directed acyclic graph (DAG). A topological sort must exist.
Intra-module file cycles (files cycling within the same module directory) produce a warning, not a failure — they often arise from mutually-recursive type definitions. Inter-module cycles are always a failure.
What the checker detects
- Fail Cycle
- An inter-module circular dependency. The full cycle path is printed — every module in the chain, with the closing edge highlighted. Even a two-module cycle (A → B → A) is caught.
- Fail Deep import
- An import that resolves inside another module's folder but isn't its
index.ts. Example:import { x } from "../billing/invoice.service"from insideorders/. - Fail Internal access
- An import from a path containing
/internal/or/_internal/that originates outside the owning module. Example:import { y } from "../build/internal/templates". - Fail Layer violation
- An import that goes in the wrong direction in the canonical dependency order. Example:
config/importing frombuild/(higher-level depending downward on a lower-level module that itself depends onconfig/). - Warn Intra-module cycle
- Two files within the same module that import each other. Does not fail by default; use
--strictto promote to failure. - Warn Type-only deep import
import type { X } from "../billing/types"— vanishes at runtime but is still a style violation. Warn; fix by re-exporting frombilling/index.ts.
Output & exit codes
One aligned report grouped by check type. Each finding includes the file, line, and a one-line fix hint. --json for CI consumption.
Clean project
patties graph
Module graph (app/modules/ — 8 modules, 47 files)
✓ No circular dependencies
✓ No boundary violations
✓ No layer violations
0 failed, 0 warnings, 3 passed
With violations
patties graph
Module graph (app/modules/ — 8 modules, 47 files)
✗ Circular dependency
orders/order.service.ts
→ billing/index.ts
→ notifications/index.ts
→ orders/order.service.ts ← closes the cycle
Fix: extract the shared concept to a new module both can import.
✗ Deep import app/modules/orders/order.service.ts:3
import { ledger } from "../billing/ledger"
→ app/modules/billing/index.ts (its public surface)
⚠ Intra-module cycle app/modules/billing/
invoice.service.ts ↔ invoice.types.ts
Fix: move shared types to a dedicated types.ts with no imports.
2 failed, 1 warning, 1 passed
patties graph --json
// patties graph --json
{
"summary": { "failed": 2, "warnings": 1, "passed": 1 },
"checks": [
{
"id": "cycle",
"status": "fail",
"chain": ["orders/order.service.ts", "billing/index.ts",
"notifications/index.ts", "orders/order.service.ts"],
"remedy": "Extract the shared concept to a new module."
},
{
"id": "deep-import",
"status": "fail",
"file": "app/modules/orders/order.service.ts",
"line": 3,
"specifier": "../billing/ledger",
"remedy": "app/modules/billing/index.ts"
}
]
}
- Exit 0
- All checks passed (warnings allowed unless
--strict) - Exit 1
- One or more checks failed
- Exit 2
- Could not run — not inside a patties project, or
bunmissing
How it works
Three passes over the source tree using Bun primitives. No bundling; no type-checking; pure import-graph analysis.
flowchart LR
GLOB["Bun.Glob\napp/modules/**/*.ts(x)"]
EXTRACT["Bun.Transpiler\nextract import specifiers\nper file"]
RESOLVE["Bun.resolve()\nspecifier → absolute path"]
GRAPH["build directed graph\nfile → Set of file deps"]
MODULE["collapse to module graph\nfile → module via folder name"]
CYCLE["DFS cycle detection\ncolor marking (white/grey/black)"]
BOUNDARY["boundary check\ncross-module non-index imports"]
REPORT["aligned report\n+ --json"]
GLOB --> EXTRACT --> RESOLVE --> GRAPH --> MODULE
MODULE --> CYCLE
MODULE --> BOUNDARY
CYCLE --> REPORT
BOUNDARY --> REPORT
bun build subprocess needed.Cycle detection — DFS with colour marking
Each module node is coloured: white (unvisited), grey (on the current DFS stack), black (fully processed). An edge from grey to grey is a cycle. When detected, the checker walks back up the stack to reconstruct the full cycle path before reporting it.
// pseudo-code — the actual implementation uses Bun.Transpiler + Bun.resolve
function dfs(node, stack, color) {
color.set(node, "grey")
stack.push(node)
for (const dep of graph.get(node)) {
if (color.get(dep) === "grey") {
reportCycle(stack.slice(stack.indexOf(dep))) // the cycle chain
} else if (color.get(dep) === "white") {
dfs(dep, stack, color)
}
}
stack.pop()
color.set(node, "black")
}
CI integration
patties graph is part of the full validation gate alongside bun typecheck and bun test. Run it as a required check on every PR.
# package.json scripts
{
"scripts": {
"check": "biome check && tsc --noEmit",
"graph": "patties graph --strict",
"validate": "bun run check && bun run graph && bun test && knip"
}
}
# patties.config.ts — optional per-project module root override
export default defineConfig({
modules: {
root: "app/modules", // default
layers: ["platform", "core", "features", "api"] // optional ordering
}
})
Note
When layers are configured, the checker enforces that a module in layer N may only import from layers 0..N-1. This makes the layer model explicit and machine-enforced rather than a convention in a README.
Fixing violations
- Deep import
- Move the import to point at the owning module's
index.ts. If the value isn't re-exported there, add the re-export. If you don't want to expose it, it's a signal that the dependency is a design smell — the consumer may need to move. - Internal access
- Same as deep import, but stronger: if the code is in
internal/, it was deliberately hidden. Either promote it to the public API (re-export fromindex.ts) or move the consuming code inside the owning module. - Cycle (two modules)
- A ↔ B usually means one of them shouldn't know about the other. Extract the shared concept (a type, an interface, a small utility) into a third module that both import. Neither A nor B imports the other.
- Cycle (three+ modules)
- A → B → C → A is spaghetti. Find the edge that shouldn't exist: which dependency is really a reaction (an event or callback) rather than a direct call? Replace the direct import with an event/callback interface that the owning module accepts as a parameter. The direction of the dependency flips.
- Intra-module cycle
- Usually mutual type references. Extract shared types into a
types.tsfile with no imports, or consolidate the two files. If both files genuinely need each other's implementation, they should be one file.
The adapter pattern for stubborn cycles
If module A needs to trigger behaviour in module B, but B also depends on A: declare an interface in a shared module, have B implement it, and have A accept it as a constructor argument. A never imports B; B still knows about A's interface. Dependency direction becomes explicit in the type signature.
Frontend & UI — React 19 + patties-ui
The frontend answer is React 19 + the patties-ui copy-in catalog, and that is the right and sufficient primitive layer — swapping frameworks is not a frontend choice, it is a different framework. The real question is the three layers above the primitives, and where each lives.
A UI is four layers — a catalog is only the first
A copy-in catalog (buttons, dialogs, inputs) is the primitive layer. A modular monolith needs three more that shadcn-style catalogs deliberately don't ship. A DataTable, not a styled <Input>, is what a back-office app lives or dies on.
- Ships ① Primitives
patties-ui— 60 copy-in components,radix-ui+ Tailwind v4, SSR-first withisland: true | false | "subtree". This layer is done.- Draft ② Forms & mutations
- Form state +
zodvalidation + a server-action mutation primitive. TouchesPattiesContextand the standards boundary → belongs in patties core. - Draft ③ Composed blocks
- DataTable, dashboard shell, command palette, auth screens — a "blocks" tier in the
patties-uiregistry, built on the 60 primitives and stamped bypatties add. - Opt-in ④ Client data cache
- TanStack Query per interactive island only — not framework-mandated, keeping the zero-JS-by-default story for the static majority.
Don't change frontend frameworks
React 19 + patties-ui is correct and sufficient as the primitive layer. The work is the forms primitive (core), the blocks tier (patties-ui), and an opt-in client cache.
Full breakdown — the four layers, design constraints, and where each one lives — on the Frontend & UI stack detail page →