Skip to content
Docs

Command palette

Jump to a page or search the docs.

Changelog

ADR-0002 — Server-capable modules have no barrel

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-07-30

Enforcement: pnpm exec oxlint (nested configs extending packages/config/oxlint-client-boundary.json), enumerated package exports, and apps/web/scripts/assert-client-bundle-clean.mjs in pnpm --filter web build.

Context

A barrel is a module whose job is to re-export its neighbours (index.ts), or — more insidiously — a module that carries real logic and re-exports a few conveniences for someone else. Barrels are pleasant to import from and they are the reason server code ends up in a browser.

The failure is not hypothetical. A production build of apps/web on 2026-07-30 shipped the database's DDL to every anonymous visitor:

Client assetLeaked content
assets/auth-*.js (37 KB)pgTable definitions for user, session, account, verification, two_factor, rate_limit — column names including impersonated_by — plus drizzle's Table/Column machinery (Symbol.for("drizzle:entityKind")). Imported by the landing page, the dashboard and the flags chunks.
assets/index-*.jsthe todo table literal and its todo_userId_idx index
assets/flags-*.js (19 KB)feature_flag and app_update_config table literals

Nobody wrote an import of the database from a component. The chain was built out of good intentions:

routes/_auth/todos.tsx        import { PRIORITIES } from "@autofolio/api/routers/todo.schema"
  → api/routers/todo.schema   import { PRIORITIES } from "@autofolio/db/schema/todo"
    → db/schema/todo.ts       export const PRIORITIES = [...]   ← the constant the UI wanted
                              export const todo = pgTable(...)  ← the side effect it got

PRIORITIES was declared in the drizzle table module so the enum would have a single source of truth — a good instinct. But pgTable(...) runs at module scope, no package declares sideEffects, so Rollup must keep it. Importing one three-element array pulled in the schema of the authentication system. The same shape leaked FLAG_KINDS, and ADMIN_ROLES sat inside lib/admin-api.ts next to @orpc/server and better-auth/api imports, saved from shipping only by tree-shaking that nothing guarantees across a dependency bump.

The one thing that was protecting us is worth naming, because it is thinner than it looks. apps/web/src/platform/rpc/orpc.ts value-imports the entire appRouter — every procedure, the audit seam, all schemas — and is in the client graph of every page. It never reaches the browser because the TanStack Start compiler strips the createIsomorphicFn().server() branch and DCE removes the now-unused imports. Correctness there rests on a framework transform, not on the type system. Mobile and native already model the safer shape (import type { AppRouterClient }), where one erased keyword is all that stands between the app binary and the backend.

Decision

A module that can reach the server — a database connection, env/server, Better Auth, the storage or email transport, an oRPC router — exports no conveniences for anyone else. No barrel, no re-export of a neighbour's constant, no "while you're here". Its public surface is the server capability and nothing more.

Anything a client needs is a leaf: a module with zero imports, or imports only of other leaves. Domain enums (PRIORITIES, FLAG_KINDS, ADMIN_ROLES) live in dependency-free modules. The drizzle schema and the API contract both import from the leaf; neither is the leaf's owner.

A client's address for a shared type is type-only by construction. @autofolio/api/client exports types and not one value, so import type versus import stops being a distinction that matters.

The drizzle schema barrel (packages/db/src/schema/index.ts) stays. It is the sanctioned exception: drizzle's relations builder and drizzle-kit genuinely want every table in one object, it is reached only from relations.ts, and it is unreachable from any client. A barrel is fine when the aggregate is the interface. That is rare.

Enforcement is mechanical, not cultural — nested oxlint no-restricted-imports configs with allowTypeImports: true, package exports maps that enumerate real subpaths instead of "./*", and "sideEffects": false on the packages whose modules are pure declarations.

Why not the alternatives

"Just be careful." Already tried; the comment in db/schema/todo.ts explicitly invited the UI to share the constant, and the leak followed. A convention with no failing build is a convention that decays.

"Rely on tree-shaking." It is what we were relying on, and it delivered 57 KB of DDL to anonymous visitors. "sideEffects": false improves the odds but still asks a bundler to prove absence of side effects across every future dependency version. We want the import to be impossible, not usually elided.

"Split every server package into @scope/x-server and @scope/x-client." Twenty packages become forty, and the boundary moves from a lint rule into pnpm-workspace.yaml. Subpath exports draw the same line inside one package for none of the churn.

"Ban barrels everywhere, including UI." Overshoots. apps/mobile/src/design/index.ts and packages/errors aggregate client-safe modules and nothing can leak through them. The rule is about reachability of server capability, not about the index.ts filename.

Consequences

  • Importing a domain enum now costs an extra hop through a leaf module. That is the point: the hop is where the boundary lives.
  • packages/api's "." entry is gone from the exports map (nothing imported it) and the "./*" wildcards on api, auth and db are enumerated. Adding a new cross-package import now means adding an export entry — a deliberate act, reviewable in a diff.
  • apps/web/src/platform/rpc/orpc.ts keeps its value import of appRouter; SSR genuinely needs the value. It is the single sanctioned exception and is carved out by path in the lint config, so it reads as a decision instead of as an accident.
  • A leaf module cannot grow. The moment someone adds an import to schema/todo.enums.ts, the boundary is gone and no test fails. Reviewers must treat "this file imports nothing" as the invariant it is.
  • Bundle forensics is the regression test: build apps/web and grep dist/client for drizzle:entityKind, impersonated_by, todo_userId_idx, feature_flag, HYPERDRIVE. All must be absent.
  • The reference implementation predates this ADR: packages/flags/src/index.ts deliberately omits ./repo with a comment explaining why. That is the pattern, generalized.
  • docs/error-handling.md already drew the same line for packages/errors ("." client-safe, @orpc/server quarantined in /server).
  • ADR-0001 for the storage package's key rules; this ADR removes that package's re-export of ./config constants.
Edit on GitHubDocumentation is in English.

We use essential cookies to make our app work. With your consent, we also use optional cookies to improve your experience. Read our Cookie Policy and Privacy Policy.