SOTA Structure
File/folder structure doctrine for TypeScript projects and monorepos, distilled from the 2025/2026 consensus (bulletproof-react, TkDodo, Kent C. Dodds, Turborepo, Hono, and the real source of the vercel/shadcn/astro/changesets CLIs), plus the audit procedure that enforces it. All numbers and citations: references/evidence.md.
A repo's own contract (AGENTS.md / CLAUDE.md) overrides this skill — but when the contract conflicts with measured evidence, flag the conflict instead of silently following either side.
Core Model
- Vertical slices over horizontal layers. Organize by feature/domain. Technical-type dirs (
components/, hooks/, services/) are allowed only inside a slice, in the shared tier, or in tiny apps.
- Unidirectional imports: shared → features → app. Features never import each other; code promotes to shared on its second consumer (rule of two). The rule only exists if machine-enforced.
- Colocate first, extract later. Code lives as close as possible to where it is used.
- The folder carries context; the file carries the concept. Never repeat a path segment in the basename.
- Barrels only at real package boundaries.
- A test lives next to what it tests.
Monorepo Layout
apps/ + custom top-level groups (cli/, libs/, tooling/) are all first-class — Turborepo/pnpm discover workspaces via globs, and vercel/turborepo itself uses custom groups. Never nest workspaces.
- Public CLI binaries: unscoped + branded (
turbo, vitest, shadcn). Libraries and secondary CLIs: one consistent branded scope with agnostic leaf names (@scope/core, @scope/add). Nobody ships an @acme-style placeholder scope.
- Internal packages: compiled + publishable when any handoff/copy/declaration contract exists; just-in-time source exports only when every consumer transpiles.
Per-Surface Taxonomy
Principles above apply everywhere; the bulletproof-react dir taxonomy applies to UI surfaces only.
| Surface |
Structure |
Never |
| React SPA / web app |
app/ (providers + router, thin routes); features/<x>/{api,components,hooks,stores,types,utils} created on demand; shared components/, hooks/, lib/, types/, config/, testing/ |
logic in route files; cross-feature imports; empty skeleton subfolders |
| Ink TUI |
same as React — Ink IS React: app/ (providers + router + thin screen pages in app/screens//app/overlays/ that compose features); features/<x>/{components,hooks,…} hold the substance; shared components/, hooks/, lib/ |
putting feature components/logic in app/ instead of thin pages (pages compose, features hold); providers or router as a top-level sibling of app/; blindly copying web-only dirs (fetch-style api/) into a TUI |
| Command CLI |
commands/ — one file or folder per command, spec + handler split (vercel-style); domain logic in utils/<domain>/ or lib packages, unit-testable without spawning the CLI |
bulletproof taxonomy; business logic inside arg parsing |
| Hono / small server |
one Hono instance per route group mounted via app.route(); createApp() factory separate from the serve entry; zod schemas colocated next to routes; middlewares/; SSE/streaming behind a transport→adapter→pipeline seam |
Rails-style controllers — Hono's own docs warn they break path-param type inference |
| Publishable library |
domain modules behind one src/index.ts public entry + granular exports subpaths; precise sideEffects |
tests/stories leaking into dist or registry/copy output; features/ taxonomy |
| Docs app |
consumes the real packages |
mirroring library source into the docs app |
Embedded server inside a CLI: the command is a thin launcher adapter; the server is its own module or workspace (astro/vercel pattern).
Naming
- kebab-case for all files and folders; PascalCase/camelCase only for exported symbols. (TanStack core and vitest use camelCase files — a per-repo tradition; never mix conventions within one repo.)
- Basename = the file's primary export, kebab-cased. Ban grab-bags:
utils.ts, helpers.ts, common.ts, misc.ts, shared.ts.
- No hard hyphen cap exists anywhere — linters cap case, never length. But elite repos sit at 82–99% of files with ≤1 hyphen (vercel CLI: 18% with 2+; bulletproof-react: 0%). The mechanism is the folder-context move, not abbreviation:
features/review/hooks/use-review-results-keyboard.ts → use-results-keyboard.ts — the path already says review.
- 2+ hyphens in a basename = shorten-via-context candidate; 3+ = split/rename smell.
- Path-echo is redundancy, not description. A basename repeating a path segment is a finding. "The name reads fine standalone" is not a defense — files are read at their path.
- Exempt from hyphen counting: the
use- hook prefix, the <component>-<part> compound idiom (menu-item.tsx), and tooling dot-suffixes.
- Dots only for tooling-known suffixes (
.test, .spec, .e2e, .stories, .config, .d); hyphens separate words. Angular v20 dropped its .component/.service dot-types for dash-style names to converge with the React/TS ecosystem.
- Dot-segments are not word separators.
review.routes.ts, user.service.ts, foo.command.ts (NestJS style) are findings. Apply the folder-context move instead: flat routes/review.ts, or at 3+ files a routes/review/ folder containing route.ts, schema.ts, stream.ts. Inside a unit folder, files drop the unit name entirely: commands/review/command.ts + handler.ts, never review.command.ts.
Barrels — Verdict Table
| Barrel |
Verdict |
Package public entry src/index.ts |
ALLOWED — the one sanctioned barrel (TkDodo) |
Granular package.json subpath exports (./hooks, ./git) |
PREFERRED over one fat entry for non-tiny packages |
Per-component index.ts where each component folder is a distribution unit (shadcn-style registry copy) |
ALLOWED — it IS a public entry, per component |
Internal convenience barrel (features/x/index.ts, hooks/index.ts re-exports) |
REMOVE — import concrete files |
| Importing your own package through its barrel/subpath from inside the package |
FORBIDDEN — direct relative imports inside the boundary |
Measured cost of internal barrels: TkDodo −68% modules (11k→3.5k); Vitest −85% transformed files; Atlassian −75% CI build minutes; Vercel −28% builds, up to −40% cold starts. bulletproof-react removed its own barrels and now recommends importing files directly.
Folders, Grouping, Colocation
- Default: flat siblings —
button.tsx + button.test.tsx next to each other.
- A unit gets its own folder at 3+ files. A folder never gets an
index.ts just because it exists.
- Tests: colocated
<name>.test.ts(x); integration as <name>.integration.test.ts; e2e per app under tests/e2e/*.e2e.ts; never __tests__/, never a parallel unit-test tree.
- Stories/docs files colocate the same way; the build must exclude them from dist and registry/copy output.
File Size & Splitting
- Target ≤200 lines, warn >300, hard review >350 — counted per responsibility, not mechanically. Generated/data files exempt.
- One main export per file + its small private helpers. Extract helpers only on proven reuse.
- Do NOT split cohesive sequential code — pipelines, state machines, protocol seams (Locality of Behaviour / Carmack's inlining argument). A cohesive 350-line state machine beats five fragments that force frame-switching.
- Orchestrator split: when a file genuinely splits, the original filename stays as the orchestrator re-exporting the unchanged public surface; the parts become siblings. Public imports never break.
Decision Guide
"Where does this code go?"
- Used by one feature → that feature's folder, named without the feature prefix.
- Needed by a second feature in the same app → app-shared tier (
hooks/, components/, lib/).
- Needed by a second app/package AND generic outside the product → extract to the owning lib (rule of two, real named concept, clearer call sites).
- Product-specific composition, copy, domain flows → stays in the app forever.
"Folder or file?" → file, until 3+ siblings. "Barrel?" → only at a package boundary. "Split?" → only at 2+ responsibilities, not at a line count alone.
Enforcement — Wire It, Don't Prose It
- dependency-cruiser: layer boundaries, no-circular, no-orphans
- knip: dead files, exports, dependencies
- Biome / eslint-plugin-unicorn
filename-case: kebab-case
- lint bans: internal barrels; self-package barrel imports; deep imports bypassing another package's public exports
Structural Refactor Protocol (mass moves/renames)
- Pure-move commit first — moves/renames with ZERO logic edits, so git rename detection survives and review shows renames, not delete+add. Splits and logic changes go in stacked commits after.
- Codemod with ts-morph for cross-package moves — IDE import-update is unreliable across package boundaries (TypeScript #59136, closed not-planned).
- Lockstep handoff rule: a rename touching a published/copy surface updates source, registry JSON, generated bundles, docs, examples, and consumers atomically in one PR.
- Pre-1.0 is the cheap window. Every rename after first publish is a breaking contract change.
- Gates between phases: full type-check (project refs make boundary breakage a hard error) → FULL test suite (not affected-only) → artifact/registry validation → smoke/e2e →
git diff --check.
Audit Procedure
When auditing structure against this bar:
- Measure (exclude generated code): hyphen distribution per basename; file-size buckets (>200/>300/>350); barrel census (public entry vs internal re-export); test placement patterns; grab-bag basenames; path-echo names; boundary violations (cross-feature imports, shared→app); mirrored/duplicated trees.
- Every finding needs file:line evidence and a concrete fix (exact target name/path).
- Spec fixes in this phase order: pure moves/renames → DRY/extractions → barrel dissolution → splits & local fixes → enforcement wiring → docs. Gates between phases per the protocol above.
Rationalizations — Counters
| Excuse |
Reality |
| "Don't churn a working file over a name" |
Pre-publish, a path-echo rename is a one-line codemod; post-publish it's a breaking change. Now IS the cheap moment. |
| "The long name reads fine standalone" |
Files are read at their path. features/review/hooks/use-review-results-keyboard.ts says review twice. |
| "One more index.ts is convenient" |
Convenience for one importer, measured cost for every build/test/bundle (−68…−85% module counts after removal). |
| "Split everything past 200 lines" |
The threshold is per responsibility. Splitting a cohesive state machine is a regression. |
| "apps/ + packages/ is mandatory" |
Turborepo discovers via globs; vercel/turborepo itself uses custom groups (cli/, crates/). Custom groups are first-class. |
| "Controllers will organize the server" |
Hono's own docs: controllers break param type inference. Handlers stay at the route site. |
| "We'll enforce boundaries by convention" |
Unenforced boundaries decay. dependency-cruiser or it didn't happen. |
1---2name: sota-structure3description: SOTA Structure4---56# SOTA Structure78File/folder structure doctrine for TypeScript projects and monorepos, distilled from the 2025/2026 consensus (bulletproof-react, TkDodo, Kent C. Dodds, Turborepo, Hono, and the real source of the vercel/shadcn/astro/changesets CLIs), plus the audit procedure that enforces it. All numbers and citations: [references/evidence.md](references/evidence.md).910A repo's own contract (AGENTS.md / CLAUDE.md) overrides this skill — but when the contract conflicts with measured evidence, flag the conflict instead of silently following either side.1112## Core Model13141. **Vertical slices over horizontal layers.** Organize by feature/domain. Technical-type dirs (`components/`, `hooks/`, `services/`) are allowed only inside a slice, in the shared tier, or in tiny apps.152. **Unidirectional imports:** shared → features → app. Features never import each other; code promotes to shared on its **second** consumer (rule of two). The rule only exists if machine-enforced.163. **Colocate first, extract later.** Code lives as close as possible to where it is used.174. **The folder carries context; the file carries the concept.** Never repeat a path segment in the basename.185. **Barrels only at real package boundaries.**196. **A test lives next to what it tests.**2021## Monorepo Layout2223- `apps/` + custom top-level groups (`cli/`, `libs/`, `tooling/`) are all first-class — Turborepo/pnpm discover workspaces via globs, and vercel/turborepo itself uses custom groups. Never nest workspaces.24- Public CLI binaries: **unscoped + branded** (`turbo`, `vitest`, `shadcn`). Libraries and secondary CLIs: one consistent branded scope with **agnostic leaf names** (`@scope/core`, `@scope/add`). Nobody ships an `@acme`-style placeholder scope.25- Internal packages: compiled + publishable when any handoff/copy/declaration contract exists; just-in-time source exports only when every consumer transpiles.2627## Per-Surface Taxonomy2829Principles above apply everywhere; the bulletproof-react **dir taxonomy applies to UI surfaces only**.3031| Surface | Structure | Never |32|---|---|---|33| React SPA / web app | `app/` (providers + router, thin routes); `features/<x>/{api,components,hooks,stores,types,utils}` created on demand; shared `components/`, `hooks/`, `lib/`, `types/`, `config/`, `testing/` | logic in route files; cross-feature imports; empty skeleton subfolders |34| Ink TUI | same as React — Ink IS React: `app/` (providers + router + **thin screen pages** in `app/screens/`/`app/overlays/` that compose features); `features/<x>/{components,hooks,…}` hold the substance; shared `components/`, `hooks/`, `lib/` | putting feature components/logic in `app/` instead of thin pages (pages compose, features hold); providers or router as a top-level sibling of `app/`; blindly copying web-only dirs (fetch-style `api/`) into a TUI |35| Command CLI | `commands/` — one file or folder per command, spec + handler split (vercel-style); domain logic in `utils/<domain>/` or lib packages, unit-testable without spawning the CLI | bulletproof taxonomy; business logic inside arg parsing |36| Hono / small server | one Hono instance per route group mounted via `app.route()`; `createApp()` factory separate from the serve entry; zod schemas colocated next to routes; `middlewares/`; SSE/streaming behind a transport→adapter→pipeline seam | Rails-style controllers — Hono's own docs warn they break path-param type inference |37| Publishable library | domain modules behind one `src/index.ts` public entry + granular `exports` subpaths; precise `sideEffects` | tests/stories leaking into dist or registry/copy output; `features/` taxonomy |38| Docs app | consumes the real packages | mirroring library source into the docs app |3940Embedded server inside a CLI: the command is a thin launcher adapter; the server is its own module or workspace (astro/vercel pattern).4142## Naming4344- **kebab-case** for all files and folders; PascalCase/camelCase only for exported symbols. (TanStack core and vitest use camelCase files — a per-repo tradition; never mix conventions within one repo.)45- **Basename = the file's primary export**, kebab-cased. Ban grab-bags: `utils.ts`, `helpers.ts`, `common.ts`, `misc.ts`, `shared.ts`.46- **No hard hyphen cap exists anywhere** — linters cap case, never length. But elite repos sit at 82–99% of files with ≤1 hyphen (vercel CLI: 18% with 2+; bulletproof-react: 0%). The mechanism is the **folder-context move**, not abbreviation:47 - `features/review/hooks/use-review-results-keyboard.ts` → `use-results-keyboard.ts` — the path already says review.48 - 2+ hyphens in a basename = shorten-via-context candidate; 3+ = split/rename smell.49- **Path-echo is redundancy, not description.** A basename repeating a path segment is a finding. "The name reads fine standalone" is not a defense — files are read at their path.50- Exempt from hyphen counting: the `use-` hook prefix, the `<component>-<part>` compound idiom (`menu-item.tsx`), and tooling dot-suffixes.51- **Dots only for tooling-known suffixes** (`.test`, `.spec`, `.e2e`, `.stories`, `.config`, `.d`); hyphens separate words. Angular v20 dropped its `.component`/`.service` dot-types for dash-style names to converge with the React/TS ecosystem.52- **Dot-segments are not word separators.** `review.routes.ts`, `user.service.ts`, `foo.command.ts` (NestJS style) are findings. Apply the folder-context move instead: flat `routes/review.ts`, or at 3+ files a `routes/review/` folder containing `route.ts`, `schema.ts`, `stream.ts`. Inside a unit folder, files drop the unit name entirely: `commands/review/command.ts` + `handler.ts`, never `review.command.ts`.5354## Barrels — Verdict Table5556| Barrel | Verdict |57|---|---|58| Package public entry `src/index.ts` | ALLOWED — the one sanctioned barrel (TkDodo) |59| Granular `package.json` subpath exports (`./hooks`, `./git`) | PREFERRED over one fat entry for non-tiny packages |60| Per-component `index.ts` where each component folder is a distribution unit (shadcn-style registry copy) | ALLOWED — it IS a public entry, per component |61| Internal convenience barrel (`features/x/index.ts`, `hooks/index.ts` re-exports) | REMOVE — import concrete files |62| Importing your own package through its barrel/subpath from inside the package | FORBIDDEN — direct relative imports inside the boundary |6364Measured cost of internal barrels: TkDodo −68% modules (11k→3.5k); Vitest −85% transformed files; Atlassian −75% CI build minutes; Vercel −28% builds, up to −40% cold starts. bulletproof-react removed its own barrels and now recommends importing files directly.6566## Folders, Grouping, Colocation6768- Default: **flat siblings** — `button.tsx` + `button.test.tsx` next to each other.69- A unit gets its own folder at **3+ files**. A folder never gets an `index.ts` just because it exists.70- Tests: colocated `<name>.test.ts(x)`; integration as `<name>.integration.test.ts`; e2e per app under `tests/e2e/*.e2e.ts`; never `__tests__/`, never a parallel unit-test tree.71- Stories/docs files colocate the same way; the build must exclude them from dist and registry/copy output.7273## File Size & Splitting7475- Target ≤200 lines, warn >300, hard review >350 — counted **per responsibility**, not mechanically. Generated/data files exempt.76- One main export per file + its small private helpers. Extract helpers only on proven reuse.77- Do NOT split cohesive sequential code — pipelines, state machines, protocol seams (Locality of Behaviour / Carmack's inlining argument). A cohesive 350-line state machine beats five fragments that force frame-switching.78- **Orchestrator split:** when a file genuinely splits, the original filename stays as the orchestrator re-exporting the unchanged public surface; the parts become siblings. Public imports never break.7980## Decision Guide8182"Where does this code go?"83841. Used by one feature → that feature's folder, named **without** the feature prefix.852. Needed by a second feature in the same app → app-shared tier (`hooks/`, `components/`, `lib/`).863. Needed by a second app/package AND generic outside the product → extract to the owning lib (rule of two, real named concept, clearer call sites).874. Product-specific composition, copy, domain flows → stays in the app forever.8889"Folder or file?" → file, until 3+ siblings. "Barrel?" → only at a package boundary. "Split?" → only at 2+ responsibilities, not at a line count alone.9091## Enforcement — Wire It, Don't Prose It9293- **dependency-cruiser**: layer boundaries, no-circular, no-orphans94- **knip**: dead files, exports, dependencies95- **Biome / eslint-plugin-unicorn** `filename-case`: kebab-case96- lint bans: internal barrels; self-package barrel imports; deep imports bypassing another package's public exports9798## Structural Refactor Protocol (mass moves/renames)991001. **Pure-move commit first** — moves/renames with ZERO logic edits, so git rename detection survives and review shows renames, not delete+add. Splits and logic changes go in stacked commits after.1012. **Codemod with ts-morph** for cross-package moves — IDE import-update is unreliable across package boundaries (TypeScript #59136, closed not-planned).1023. **Lockstep handoff rule:** a rename touching a published/copy surface updates source, registry JSON, generated bundles, docs, examples, and consumers atomically in one PR.1034. **Pre-1.0 is the cheap window.** Every rename after first publish is a breaking contract change.1045. **Gates between phases:** full type-check (project refs make boundary breakage a hard error) → FULL test suite (not affected-only) → artifact/registry validation → smoke/e2e → `git diff --check`.105106## Audit Procedure107108When auditing structure against this bar:1091101. **Measure** (exclude generated code): hyphen distribution per basename; file-size buckets (>200/>300/>350); barrel census (public entry vs internal re-export); test placement patterns; grab-bag basenames; path-echo names; boundary violations (cross-feature imports, shared→app); mirrored/duplicated trees.1112. Every finding needs file:line evidence and a concrete fix (exact target name/path).1123. Spec fixes in this phase order: pure moves/renames → DRY/extractions → barrel dissolution → splits & local fixes → enforcement wiring → docs. Gates between phases per the protocol above.113114## Rationalizations — Counters115116| Excuse | Reality |117|---|---|118| "Don't churn a working file over a name" | Pre-publish, a path-echo rename is a one-line codemod; post-publish it's a breaking change. Now IS the cheap moment. |119| "The long name reads fine standalone" | Files are read at their path. `features/review/hooks/use-review-results-keyboard.ts` says review twice. |120| "One more index.ts is convenient" | Convenience for one importer, measured cost for every build/test/bundle (−68…−85% module counts after removal). |121| "Split everything past 200 lines" | The threshold is per responsibility. Splitting a cohesive state machine is a regression. |122| "apps/ + packages/ is mandatory" | Turborepo discovers via globs; vercel/turborepo itself uses custom groups (`cli/`, `crates/`). Custom groups are first-class. |123| "Controllers will organize the server" | Hono's own docs: controllers break param type inference. Handlers stay at the route site. |124| "We'll enforce boundaries by convention" | Unenforced boundaries decay. dependency-cruiser or it didn't happen. |