Architect
Standards
ReferenceNormative

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

FileAudienceHolds
index.tsAny featureDomain types and pure functions. The service contract.
client.tsPresentationComponents and view models. Safe in a client bundle.
server.tsRoutes and components calling the serverServer functions a client may invoke.
server-only.tsOther features' server codeComposition 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>-internals rule per ordered pair, generated in a loop — 18 features produced 306 rules, and they cost nothing to maintain because they are derived.
  • index.ts may 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/ and application/ may not import a client.ts facade.
  • ui/, lib/ and _shared/ may not import a feature — they are lower layers.
  • config/, db/ and cross-cutting infrastructure may not import features or ui/. 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's ui/ may not reach adapters/, application/ports/ or server/ 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/. Not ui, not lib, not routes, not a feature's domain/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.ts suffix on every server-only module — the boundary is visible in the filename, before any import graph is consulted.
  • module.ts charter per feature: { name, responsibility }, with the feature-name union declared once in _shared/module.ts and 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's ui/ 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.ts per 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/ or common/ 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.

On this page