# ADR-0002 — Server-capable modules have no barrel

The repository's architectural decision, rationale and consequences.

# ADR-0002 — Server-capable modules have no barrel

**Status:** accepted · 2026-07-30

## Context [#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 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 [#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 [#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 [#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.

## Related [#related]

- 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.

