Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

ADR-0002 — Server-capable modules have no barrel

The repository's architectural decision, rationale and consequences.

Markdown

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 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 "@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 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/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 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/utils/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.