Overview
The folder tree is the second piece of documentation a reader sees, after the README. A shallow, predictable hierarchy lets agents and humans guess the right path on the first try. Deep, type-driven trees force a directory archaeology before any change. The rules below cap depth, group by feature, mandate index files, and enforce one concept per folder. They build on the file-naming rules in naming-conventions.
Cap nesting at four levels
Past four directory segments below the project root, the path stops being readable.
- Good:
src/billing/invoice/parser.ts(3 levels). - Bad:
src/modules/services/billing/invoice/parsers/xml/v2/parser.ts(8 levels).
Each segment must carry information the others do not. If two adjacent segments could be merged without ambiguity, merge them. src/components/forms/login/LoginForm.tsx becomes src/forms/LoginForm.tsx; the components/ segment was filler.
The “four levels” rule is a heuristic, not a law. Cross it only when a real domain boundary justifies it.
Group by feature, not by type
Type-grouped folders (components/, services/, models/, utils/) scatter the files for one feature across the tree. Feature-grouped folders keep related code together.
# Bad (type-grouped)
src/
├── components/
│ ├── UserCard.tsx
│ └── InvoiceTable.tsx
├── services/
│ ├── userService.ts
│ └── invoiceService.ts
└── models/
├── User.ts
└── Invoice.ts
# Better (feature-grouped)
src/
├── user/
│ ├── UserCard.tsx
│ ├── service.ts
│ └── model.ts
└── invoice/
├── InvoiceTable.tsx
├── service.ts
└── model.ts
When user/ ships, every file ships. When user/ deletes, every file deletes. Feature folders survive refactors; type folders fragment them.
The exception: a folder of genuinely cross-cutting utilities (lib/, shared/) is fine. It earns its place when at least three features import from it. Below that, duplicate.
One folder equals one concept
A folder name is a noun phrase that describes what every file inside has in common. If a contributor opens the folder and asks “why is this file here?” the folder is misnamed.
auth/holds authentication code. A login form, a JWT verifier, an OAuth callback. Yes.auth/also holds the analytics dashboard “because login events go to analytics.” No. Move it toanalytics/and have it import fromauth/.utils/is the canonical bad folder. Almost every “util” belongs with its feature; what is left is usually one or two helpers that deserve a more specific home.
When a folder’s purpose blurs, split it into two folders with sharper names.
Add an index file at each level
Every directory that is a public surface has an index file that names what the directory exports or contains.
- TypeScript:
src/billing/index.tsre-exports the public API. Consumers import frombilling/, not frombilling/internal/parser. - Python:
src/billing/__init__.pylists whatfrom billing import ...exposes. - Documentation: every content folder has
index.mdas the obsidian-style MOC (Map of Content). - Quartz / Astro content:
index.mdis the folder’s landing page.
The index file gives a reader a single entry point. It also makes the folder’s contract grep-able: search for “from billing” to see what the rest of the codebase relies on. Internal files stay internal because they are not re-exported.
Internal vs public split
When a folder grows beyond a single concept, mark which files are public.
- TypeScript: a
_internal/orinternal/subfolder; theindex.tsdoes not re-export it. - Go:
internal/is enforced by the compiler. Anything underinternal/cannot be imported from outside the module. - Python: a leading underscore (
_helpers.py) signals “do not import from here.” Convention only; not enforced.
The internal/public split lets a folder refactor freely without breaking callers. Without it, every file is implicitly public and every change risks a downstream break.
Avoid folders that hold one file
A folder with a single file is overhead with no payoff. Inline it.
src/parser/parser.tsis worse thansrc/parser.ts.- Wait until a second related file exists before adding the folder.
- Exception: framework conventions (Next.js
app/[slug]/page.tsx, Quartzcontent/<category>/index.md) force the structure. Honor them.
The corollary: when a folder shrinks to one file via deletion, collapse it back.
Mirror the runtime model in the folder tree
The folder layout should reflect how the system actually runs.
- A web app with pages:
pages/orapp/mirrors URL structure. - A service with handlers:
handlers/mirrors the routes it serves. - A worker with queues:
workers/<queue-name>/per queue. - A batch job with steps:
steps/mirrors the pipeline.
When the tree reflects the runtime, a stack trace points at a folder name and the reader knows where to look. When it does not, every bug starts with a search. See large-codebase for scaling these patterns past a hundred packages.