Status: accepted · 2026-07-30
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 asset | Leaked 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-*.js | the 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 "@doro/api/routers/todo.schema"
→ api/routers/todo.schema import { PRIORITIES } from "@doro/db/schema/todo"
→ db/schema/todo.ts export const PRIORITIES = [...] ← the constant the UI wanted
export const todo = pgTable(...) ← the side effect it gotPRIORITIES 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/utils/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. @doro/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 onapi,authanddbare enumerated. Adding a new cross-package import now means adding an export entry — a deliberate act, reviewable in a diff.apps/web/src/utils/orpc.tskeeps its value import ofappRouter; 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/weband grepdist/clientfordrizzle:entityKind,impersonated_by,todo_userId_idx,feature_flag,HYPERDRIVE. All must be absent.
Related
- The reference implementation predates this ADR:
packages/flags/src/index.tsdeliberately omits./repowith a comment explaining why. That is the pattern, generalized. docs/error-handling.mdalready drew the same line forpackages/errors("."client-safe,@orpc/serverquarantined in/server).- ADR-0001 for the storage package's key rules; this ADR removes that package's re-export of
./configconstants.