The thesis in one line

A modular monolith is one deployable, partitioned into modules that communicate only through their public surface. That boundary is the whole value — everything else is ceremony Bun lets us delete. Bun's module graph already is a dependency graph; we lean on it and add one build-time rule: a module may only reach another module's public index.ts.

Intended design only

This section is a design exploration. The modular-monolith convention described here is proposed, not yet implemented — see the delivery tracker for status. The shipped framework behaviour is documented under Docs.

Deliberately thin

The goal is structure, not a framework that owns your code. Reach for a Bun or web primitive every time one exists.

✅ What it is A module convention (a folder with a public index.ts), a boundary check (build error on deep imports), a discovery macro (Bun.Glob merges routes at build), and a scaffolder.
❌ What it isn't No DI container, no decorators / reflect-metadata, no guards / pipes / interceptors / filters, no new validation framework. Wiring is plain ES import; request flow is compose().

If you delete the framework, your modules are still just folders of plain functions. That's the test for "thin."

A module is a folder

Everything inside is private by default. The index.ts is the contract — the only thing other modules are allowed to import.

app/
  billing/
    index.ts            ← public API (the front door)
    routes.ts           ← HTTP surface: (req, ctx) => Response
    invoice.service.ts  ← internal
    invoice.schema.ts   ← internal (Zod, if you want it)
  orders/
    index.ts
    routes.ts
    order.service.ts    ← imports billing ONLY via ../billing
// app/billing/index.ts — the public surface (and the ONLY import target)
export { invoiceService } from "./invoice.service";
export type { Invoice } from "./invoice.service";

// app/orders/order.service.ts — cross-module wiring is just an import
import { invoiceService } from "../billing";          // ✅ through the public index
// import { ledger } from "../billing/invoice.service" // ❌ build error: internal

Key rules

  • RULE 1Public API or nothing. A module may only import another module's index.ts. Deep imports into internals are a build error with file:line.
  • RULE 2Wiring is a plain import. Bun's module graph is the dependency graph. No container, no tokens, no reflect-metadata.
  • RULE 3Discover at build, serve frozen. A macro finds modules and inlines the merged route table. The production server runs Bun.serve({ routes }) and scans nothing.
  • RULE 4Standards at the boundary. Handlers take a standard Request + a thin ctx and return a Response. Request flow is the existing compose() middleware.
  • RULE 5Bun-native only. Bun.serve, Bun.Glob, Bun.build, bun --hot. No Express, no chokidar, no Webpack.
  • RULE 6Libraries stay libraries. Validation, ORM, jobs — call them in a handler. The framework doesn't wrap them in phases or providers.

Explore the specs

Patties design specifications · a design exploration · User docs