Feature structure
src/features with a hexagon inside, and four facades per feature instead of one barrel — the shape that survives past ten features.
Verified 2026-07-31 · Observed on a TanStack Start console with 18 features, enforced by dependency-cruiser in CI.
src/features/<feature>/, hexagonal inside, and every cross-feature import goes
through a named facade file.
The single most valuable idea here: not one barrel file per feature, but four —
one per audience. A single index.ts cannot express "importable from a React
component" versus "must never reach a client bundle", so it degrades into a
re-export of everything.
The tree
src/
features/<feature>/
domain/ pure rules and types — no framework, no I/O, no React
application/ use cases + ports/ (interfaces the domain needs)
adapters/ db schema, stores, search, external services — *.server.ts
server/ server functions / endpoint handlers — *.server.ts, *.functions.ts
ui/ React components for this feature
index.ts ← facade: the service contract (domain types, pure helpers)
client.ts ← facade: presentation (components + view types)
server.ts ← facade: client-callable server functions
server-only.ts ← facade: server-to-server composition, never bundled to a client
module.ts ← the feature's charter: name + one-line responsibility
features/_shared/ vocabulary every feature may use — imports no feature
ui/ design system: components/ shell/ styles/ i18n/
lib/ hooks/ config/ db/ routes/The four facades — the part worth copying
| File | Audience | Holds |
|---|---|---|
index.ts | Any feature | Domain types and pure functions. The service contract. |
client.ts | Presentation | Components and view models. Safe in a client bundle. |
server.ts | Routes and components calling the server | Server functions a client may invoke. |
server-only.ts | Other features' server code | Composition seams that must never be bundled client-side. |
Rules that make it hold:
- A feature may import another feature only via those four files. Everything
else is internal. One
no-<a>-to-<b>-internalsrule per ordered pair, generated in a loop — 18 features produced 306 rules, and they cost nothing to maintain because they are derived. index.tsmay not re-export outer layers (adapters/,server/,ui/, or the other facades). Without that rule the service contract quietly becomes a passthrough to everything.- Deliberate, documented exceptions only. ORM schema modules may reference another feature's schema — a foreign key is a database fact, and a table definition grants no ability to do anything. The exception is stated as a comment on the rule, which is what keeps it from spreading.
Dependency direction
domain/imports no framework (no React, no router, no icon library) and no outer layer.application/imports no adapters, no server, no ui, no framework.domain/andapplication/may not import aclient.tsfacade.ui/,lib/and_shared/may not import a feature — they are lower layers.config/,db/and cross-cutting infrastructure may not import features orui/. Importing upward inverts the direction, and is how a "shared" folder becomes a cycle.- No circular production dependencies, generated route trees excepted.
Client/server safety — the rules that actually prevent leaks
ui/,routes/and a feature'sui/may not reachadapters/,application/ports/orserver/directly — they go through a facade. Route exceptions (health probes, a mounted auth handler, webhooks) are an exact enumerated list, not a pattern. A pattern would grow.- No client-reachable layer imports raw
db/. Notui, notlib, notroutes, not a feature'sdomain/application/ui, not the public API feature. - No client-reachable layer imports server configuration. Server settings carry secrets; the client takes what it is allowed to know from an app-config the root route serves. Allowlist, not filter — see secrets.
- Temporary and prototype adapters are confined to one composition root per feature, so deleting them later is a one-line change and they can never reach a client bundle.
Conventions worth keeping
*.server.tssuffix on every server-only module — the boundary is visible in the filename, before any import graph is consulted.module.tscharter per feature:{ name, responsibility }, with the feature-name union declared once in_shared/module.tsand reused by the architecture config. A feature that cannot state its responsibility in one line is two features.- The design system lives in
src/ui/, not in a feature. A feature'sui/composes it. This layout is one expression of a rule that holds in any tree — what belongs in which layer. - Tests sit beside the code (
*.test.ts,*.integration.test.ts), and production code may not import tests or fixtures — as its own rule.
Traps
- One
index.tsper feature → it becomes a re-export of everything and the boundary is decorative. Split by audience from day one. - Boundary rules written by hand per pair → drift. Generate them from the feature list.
- A
shared/orcommon/that imports features → instant cycle. Lower layers never import up. - Route files that fetch directly → the facade is bypassed and client/server safety is no longer checkable. Routes are thin adapters.
Enforcement
dependency-cruiser (.dependency-cruiser.js) as an architecture:check gate in
CI, alongside typecheck, lint and test.
The rules themselves are tested against fixture projects — otherwise a rule that matches nothing passes silently and proves nothing.