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 a routes map in Bun.serve shape. 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;
No allow-list to maintain, no decorators to annotate. The rule derives entirely from folder layout and the import graph Bun already produces.

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 test module 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;
Left: any number of dependencies is fine as long as there's no loop. Right: three hops is all it takes to create spaghetti. 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;
Validation, authz, serialization — each is either a middleware (cross-cutting) or a plain call inside the handler (local). The framework adds no phases of its own.

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;
Blue = owner (writes). Green = consumer (read-only). Yellow = swap path (new instance replaces the reference, old instance is not touched).
Owner
build/
Mutation window
Inside build(), and during the onBuildEnd plugin hook (before Object.freeze is called)
Freeze point
build() calls Object.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 new ClientManifest after each island rebuild and atomically replaces the pointer held by server/. 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;
The production bundle ships with the route array baked in as a literal — no 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 frozen CompiledRouter. No route may be added or removed after boot.
Dev swap
dev/ is the only module permitted to call scanRoutes(). On change it creates a new CompiledRouter and 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/AgentRegistry and ToolRegistry singletons
Mutation window
During ai/scan.ts boot scan only. The registries are populated from app/agents/ and app/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() return Readonly<AgentDefinition> / Readonly<ToolDefinition>. No module outside ai/ may hold a direct registry reference.
Plugin hook
onJobsCollect receives a read-only JobSummary[] 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.vars is 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, wrapping PattiesContext.
  • 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;
The forbidden case: 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 inside orders/.
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 from build/ (higher-level depending downward on a lower-level module that itself depends on config/).
Warn Intra-module cycle
Two files within the same module that import each other. Does not fail by default; use --strict to 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 from billing/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 bun missing

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
All passes are read-only. No files are written. The whole check runs in the same process — no 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 from index.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.ts file 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 with island: true | false | "subtree". This layer is done.
Draft ② Forms & mutations
Form state + zod validation + a server-action mutation primitive. Touches PattiesContext and the standards boundary → belongs in patties core.
Draft ③ Composed blocks
DataTable, dashboard shell, command palette, auth screens — a "blocks" tier in the patties-ui registry, built on the 60 primitives and stamped by patties 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 →

Patties design specifications · a design exploration · User docs