Repository Structure
A folder tree is a navigation aid and a change-locality claim. It should reflect how engineers actually move through the system, not a template someone copied.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How should the top-level folders be laid out, and why is every answer someone gives me a template from a different project?
A new engineer needs to add a field to the checkout flow. They should be able to find every place that has to change without asking anyone.
Use the standard layout: controllers/, services/, models/, utils/. Every project in this language does it, so everyone will know where things are.
It optimises for the wrong lookup. Engineers do not arrive asking "where are the services"; they arrive asking "where is checkout", and the answer is: in five folders, one file each (Package by Layer).
- It optimises for the wrong lookup. Engineers do not arrive asking "where are the services"; they arrive asking "where is checkout", and the answer is: in five folders, one file each (Package by Layer).
- Every feature change is therefore a diff across four top-level directories, which makes the file list useless as a summary of what changed (Shotgun Surgery).
- The technical names carry no information after the first week.
services/tells you what kind of thing it is, which you can see from the code, and nothing about what it is for. utils/becomes the answer whenever the structure has no home for something, and it accumulates until it is the most-imported and least-understood folder in the repository (The Utility Dumping Ground).- The layout also fails at the thing it claims to buy: it does not prevent a controller reaching into a model owned by an unrelated feature, because folders do not enforce anything unless the build enforces them (Internal Module Contracts).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The repository already has three years of history; a wholesale reorganisation destroys blame and makes every open branch conflict.
- The build tool, the test runner and the deploy pipeline all have opinions about where things live, and some of them are not negotiable.
- Whatever is chosen, engineers will follow the shape that already exists far more reliably than any document, so the structure is the policy.
- A person who knows the domain but not this codebase can find where a named feature lives, from the tree alone, in under a minute.
- A change to one feature is visible as a change to one area, so that reviewers can see the blast radius in the file list (Change Amplification).
- The structure never becomes the reason to duplicate code, because "there is nowhere sensible to put this" is how the dumping ground starts.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The tree owns making the system's parts findable and the system's boundaries visible. That is the entire job, and layouts fail by trying to do more.
- A folder should correspond to something a person would name — a feature, a domain concept, a deployable — because that is the unit people search for.
- Enforcement of who may import what belongs to the build or a lint rule, not to the folder tree. A folder is a hint; a dependency rule is a constraint (Stable Dependencies).
- Top-level folders should correspond to the seams you actually want, so that "this change stayed inside billing" is visible in the diff (Finding Seams).
- Below the top level, layering is often exactly right:
billing/api,billing/domain,billing/dbis a useful structure because inside one feature, technical role is the real distinction (Package by Feature). - The line between the two is where the change locality is. Group by whatever a requirement change follows; subdivide by whatever a reader needs once they are inside.
Two trees, and what each one makes easy to find
The argument is not aesthetic. Put the two layouts next to each other and ask the only question that matters: for the change you are about to make, how many top-level directories does the diff touch?
Then ask the second question, which is the honest counterweight: for a security review of every database write in the system, which layout would you rather have?
1LAYER-FIRST FEATURE-FIRST2src/ src/3 controllers/ checkout/4 checkout.ts api.ts5 catalog.ts pricing.ts6 billing.ts order.ts7 services/ checkout.test.ts8 checkout.ts catalog/9 catalog.ts api.ts10 billing.ts search.ts11 models/ catalog.test.ts12 order.ts billing/13 product.ts api.ts14 utils/ invoice.ts15 helpers.ts platform/16 misc.ts db.ts # rule: no domain logic17 logging.ts18 19"add a field to checkout" "add a field to checkout"20-> 4 directories, 5 files -> 1 directory, 3 files21 22"review every DB write" "review every DB write"23-> one folder -> grep across every featureThe bottom two rows are the whole trade, and each layout wins one of them. Which one you should optimise is a question about how often each lookup happens in your team — which you can answer from your own pull request history in ten minutes.
The folder that means the structure has no answer
Every layout eventually meets code that does not obviously belong anywhere. What the team does at that moment determines the structure's fate more than the original design did.
The dumping ground is not caused by laziness. It is caused by a tree with no honest home for shared mechanism, plus a reviewer with no basis to object — because "put it somewhere better" is not actionable when nowhere better exists.
looks like A top-level folder called utils/, common/, shared/, helpers/ or lib/ that is among the most-imported in the repository, has no owner, contains files named misc.ts or index.ts with forty unrelated exports, and whose contents range from a string-padding function to the tax calculation.
suggests The structure has no home for shared mechanism, so everything shared lands in one place regardless of what it is. The consequence is not untidiness: it is that domain knowledge is now stored in a folder with no owner and no cohesion, so a change to the tax rule is a change to a file that half the codebase imports, and the blast radius of every edit there is the whole repository (God Object at directory scale).
fix Do not reorganise it wholesale. Take the domain knowledge out first — the tax rule goes to billing/, the order status helpers go to checkout/ — because that is where the real cost is. What remains is usually genuinely generic and small, and can be given a real name and a written rule. Then add a check that fails when a file in it imports from a domain module, which is the cheapest possible definition of "no domain logic here" (Dependency Direction).
platform/db, text/slug, time/clock — that contains mechanism with no domain knowledge in it and has a stated rule about what may be added. The distinguishing test is not the name but the contents: if you can state in one sentence what belongs there and what does not, and the sentence excludes anything about your business, it is a module. If the sentence is "things we use in more than one place", it is a dumping ground.Discovering the structure from the change history
The template argument is unwinnable because both sides are describing different systems. The way out is to stop arguing about the template and look at what this repository actually does.
Ten pull requests is enough. It takes ten minutes, it produces evidence instead of preference, and it usually surprises at least one person in the room — most often by revealing that the folders everyone argues about are not the ones the changes touch.
"The standard layout for this framework is
controllers / services / models / utils,
and that's what everyone expects."
Evidence used: a blog post, another project
Decided by: whoever set up the repo, in week one
Revisited: never
Failure: nobody can say what it makes cheap,
so nobody can say when it is wrong$ git log --format='%h' -n 10 | while read c; do
git show --name-only --format='' $c \
| cut -d/ -f2 | sort -u | paste -sd,
done
checkout,billing,models,utils
catalog,models,utils
checkout,models,dto,utils
...
Finding: 8 of 10 changes touch >= 3 top-level dirs
10 of 10 touch utils/
Decision: top level names features; utils/ is split
by where its knowledge actually belongs
Revisit: when a single feature folder exceeds what
one team can ownThe second version produces a decision that can be wrong, which means it can be checked, argued with and revisited. It also names what the structure is buying — fewer directories per change — so a future engineer can measure whether it is still buying it. The cost is honest: ten minutes, and a result that may well tell you the current structure is fine, which is a boring outcome that a template argument never reaches (Decision Records).
How to build it
Most important first.
- Start from the changes. List the last ten pull requests and ask which folders each touched. If most touch four top-level folders, the top level is wrong (Vertical Slices).
- Name folders after the domain, not the framework.
checkout/,billing/,catalog/are searchable by someone who has read the product docs;services/is not (Naming and Domain Language). - Keep the top level small enough to read in one screen. A tree with forty top-level entries has no structure, it has a list.
- Put tests next to what they test unless the tooling forbids it, so that moving a module moves its tests and deleting a feature deletes its tests (Docs Close to Code applies the same argument to documentation).
- Make the exceptions explicit. Genuinely cross-cutting infrastructure — the logging setup, the database connection — needs a home, and naming it
platform/orinfra/with a rule about what may go there is better than letting it becomeutils/(The Common Module). - Change it incrementally, feature by feature, as you touch things. A big-bang reorganisation costs every open branch and buys a layout you have not tested against real work (Incremental Migration).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Under layer-first: adding a field to checkout touches
controllers/,services/,models/,dto/andtests/— five directories, and the reviewer cannot tell from the file list whether anything unrelated moved. - Under feature-first: the same change is confined to
checkout/, and the diff itself is the argument that nothing else was affected. That is the entire return. - Restructuring later: cheap in tooling terms (a move is a move) and expensive in social terms — every open branch conflicts, blame needs a follow-file flag, and code review of the move itself is unreadable. Budget the conflict, not the rename.
- What does not get cheaper: a change that genuinely crosses features — adding a currency to everything — costs the same under either layout, because the knowledge is spread by the requirement and not by the folders (Duplicate Knowledge).
- Feature-first makes the common change cheap and makes genuinely cross-cutting work harder to find, because there is no single place that shows every implementation of a technical concern.
- Domain names in folders age with the domain. When the business renames "checkout" to "purchase flow", the tree is either wrong or you pay for a rename that touches every import.
- Fighting the framework's conventions to get a better layout costs configuration, plugin workarounds and a permanent source of confusion for anyone who knows the framework and not your repository.
What can go wrong
- The structure is decided at the start by one person from a blog post, and three years later nobody can justify it but everyone follows it.
- A reorganisation is done for aesthetics, blame history is scrambled, and the team loses the ability to answer "why is this line here" for a year (What "Legacy" Actually Means).
- Feature folders are adopted and then everything shared drifts into
common/, so the layout is by-feature at the top and a dumping ground underneath — the worst of both (The Common Module). - The folder boundary is treated as an enforced boundary when nothing enforces it, so the team believes it has modules and has directories.
- The tree is optimised for the newcomer's first day and ignores the daily experience of the five people who work in it, which is a real trade and is usually made unconsciously.
- The tree constrains nothing by itself; it depends on a lint or build rule to become a real boundary, and without one it is documentation that drifts (Documentation Decay).
- It is coupled to tooling: some frameworks resolve routes, migrations or plugins by convention from a path, and fighting that convention costs more than it returns (What a Framework Charges).
- It is coupled to the ownership model, because a folder is the unit most ownership tooling matches on (Code Ownership).
- "So feature folders are correct and layers are wrong." Layering inside a feature is usually right, and layer-first at the top is right for a genuinely layered product like a compiler or a codec, where the phases *are* the domain (Package by Layer).
- "There is a correct structure." There is a structure that matches how this system changes, and it is discovered from the change history, not from a template. Two teams with the same framework can correctly have different trees.
- "Reorganising will fix the coupling." Moving files changes nothing about who imports whom. If
catalogreaches intocheckout's internals, it will keep doing it from a new address (Kinds of Coupling). - "The tree is a boundary." It is a hint until something enforces it. Teams routinely believe they have modular code because they have modular folders.
- utility-dumping-ground
- shotgun-surgery
Testing it, and how it ages
- The real test of a structure is a person: hand a newcomer a feature request and watch where they look first. Everything else is theory.
- An automated dependency rule —
checkoutmay not import fromcatalog/internal— turns the intended structure into something that fails in CI rather than in review (Circular Dependencies). - A check that no folder named for a technical role accumulates domain logic is crude and surprisingly effective as a canary for the dumping ground.
- Structures decay by accretion, not by decision: one file that did not fit, then another, and the exception folder becomes the largest one.
- The layout that is right at eight engineers with one product is wrong at eighty with four, and the usual next step is a top level that names deployables or products rather than features (Module Granularity).
- A good sign it is time to change: the same word appears in three top-level folder names, which means the real boundary is that word and the tree does not have it.
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- SCALE-SPECIFICAt eight engineers in one repository, the top level should name features and there is no reason for anything more elaborate; a structure with per-team roots and enforced import boundaries is pure overhead when everyone touches everything. Past roughly forty engineers the top level usually has to name ownership units rather than features, because the tree is now doing a routing job for reviews and on-call as well as a navigation job. Copying the large-organisation shape into a small repository produces deep empty hierarchies and a permanent argument about where things go.
- FRAMEWORK-SPECIFICFrameworks that resolve routes, migrations, jobs or plugins by filesystem convention — Rails, Next.js, Django, Laravel — have already made most of this decision, and deviating costs configuration and confusion for every engineer who knows the framework. There the useful advice is narrower: put domain logic in feature modules that the convention-driven folders call into, rather than fighting the convention itself.
- CONTESTEDA strong opposing case for layer-first: it makes technical concerns auditable — a security reviewer can read every controller, a DBA every repository — and it gives newcomers a structure they already know from every other project in the ecosystem. That argument is genuinely strong for systems where the layers are the domain and for organisations where cross-cutting audit matters more than feature locality. The disagreement is about which lookup is more frequent, and the honest answer is that it depends on who is looking.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — where tests live and how they are grouped is part of this decision, and a layout that separates tests from what they test makes deleting a feature reliably leave its tests behind.