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 to analytics/ and have it import from auth/.
  • 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.ts re-exports the public API. Consumers import from billing/, not from billing/internal/parser.
  • Python: src/billing/__init__.py lists what from billing import ... exposes.
  • Documentation: every content folder has index.md as the obsidian-style MOC (Map of Content).
  • Quartz / Astro content: index.md is 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/ or internal/ subfolder; the index.ts does not re-export it.
  • Go: internal/ is enforced by the compiler. Anything under internal/ 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.ts is worse than src/parser.ts.
  • Wait until a second related file exists before adding the folder.
  • Exception: framework conventions (Next.js app/[slug]/page.tsx, Quartz content/<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/ or app/ 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.