Decomposition by Folder
The anti-lesson. Splitting by technical type puts every file of a kind together and every file of a feature apart, producing boundaries that no requirement respects.
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.
Why does a codebase organised into neat controllers/, services/ and models/ folders still make every change touch six files?
Add a "cancel subscription" feature. The codebase is immaculately organised by type. The change ends up spanning eleven files across seven folders, and the reviewer cannot see the feature at all.
Group files by what they *are*. All controllers together, all services together, all models together. It is instantly navigable, it matches the framework docs, every new engineer knows where a file goes without asking, and no design conversation is required at any point. Those are real advantages and they are why this is the most common layout in the industry.
The unit of work is a feature and the unit of grouping is a type, so the two never line up: cancellation lives in eleven files across seven folders and there is no directory you can open to see it (Shotgun Surgery).
- The unit of work is a feature and the unit of grouping is a type, so the two never line up: cancellation lives in eleven files across seven folders and there is no directory you can open to see it (Shotgun Surgery).
- Nothing is contained. Deleting the feature means finding all eleven, and since none of the folders is about cancellation, the only reliable method is grep — which finds the ones that were named consistently and misses the rest.
- Ownership becomes impossible to express. Two teams that own different features own overlapping slices of every folder, so
CODEOWNERSis either useless or a per-file list (Code Ownership). - The folders offer no encapsulation whatsoever. Everything in
services/can import everything else inservices/, so a "boundary" that the layout implies does not exist at the language level, and cross-feature reaching happens quietly (Dependency Cycles). - It produces
utils/andshared/as a structural inevitability: code that belongs to no type folder has nowhere else to go, and once created those folders only grow (The Utility Dumping Ground).
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 framework's generators, documentation and community examples all assume the type folders; going against them costs configuration and a permanent explanation.
- Every engineer on the team can already find a controller without asking. That is a real benefit and it is what makes this hard to argue against.
- The codebase is 400 files. Reorganising all of it at once is not on the table.
- A cancelled subscription stops billing at the end of the paid period and never mid-period (State Machines).
- Cancellation is idempotent: a repeated request does not extend or double anything.
- Whatever the layout, no module may reach into another feature's internal state (Internal Module Contracts).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A feature owns everything that is only about that feature: its rules, its state, its persistence queries, its HTTP surface, its tests.
- A shared module owns something *specific and named* — money, identifiers, a domain type — never "shared things".
- The framework owns the transport plumbing, and its conventions should be satisfied at the edge of a feature rather than allowed to dictate the whole layout (What a Framework Charges).
- The seam that pays is around a feature, because that is the unit a requirement arrives in and the unit that gets deleted (Package by Feature).
- Layering is still useful *inside* a feature folder — a rules file, a persistence file, a handler file. As a secondary organisation it costs nothing and helps; as the primary one it destroys containment (Package by Layer).
- The boundary needs teeth. A feature folder with no import restriction is a naming convention, and naming conventions decay; a module system, a package boundary or a lint rule makes it real (Stable Boundaries).
One feature, seven folders
Lay a real feature across a type-organised tree and the mismatch is immediate. Nothing in the table below is badly written and every file is exactly where the convention says it should be — which is the point. The layout is being followed correctly and it still fails to contain anything.
Read the last column. Not one of those folders is a unit of change: no requirement has ever asked to "change the DTOs". The layout groups by a property that no requirement is about.
- To read the feature you open seven directories; to delete it you grep and hope.
- The
utils/row is not an accident of this example. A layout with no home for feature-specific helpers manufactures a homeless folder, every time (The Utility Dumping Ground). - Nothing here prevents
services/InvoiceServicefrom importingservices/SubscriptionService's internals, because they are peers in a flat folder — the layout implies a boundary the language does not enforce.
| Folder | What the cancellation feature puts there | Who else is in this folder | Is this folder ever a unit of change? |
|---|---|---|---|
controllers/ | SubscriptionController.cancel | Every other endpoint in the system | Only for framework upgrades — roughly never |
services/ | SubscriptionService.cancel | Every other service, all mutually importable | No. This is where the feature actually lives, mixed with forty others |
models/ | Subscription, CancellationReason | Every entity, and therefore every schema concern | Only for a schema-wide migration |
dto/ | CancelRequest, CancelResponse | Every request and response shape in the system | No — DTOs change with their feature, never as a set |
validators/ | CancelValidator | Validation for unrelated features | No |
jobs/ | EndOfPeriodBillingJob | Every scheduled job | Only when the scheduler changes |
utils/ | periodEnd() — it fitted nowhere else | Whatever else fitted nowhere else | Never, and it only grows (The Common Module) |
The same two changes, priced under both layouts
The comparison worth making is not "which is prettier" but which change each layout makes cheap, because both make something cheap and they are different somethings.
The second row of the after note is the one that decides it for most product teams: features are added and deleted constantly, and a layout where deletion is a directory removal rather than an archaeology project compounds over years.
Two ordinary requests, a month apart: cancellation must record a reason code, and the trial feature is being retired entirely.
Nine files across seven folders for one field. For the trial deletion there is no folder to remove: the work is a grep for "trial", and the two helpers in utils/ that trials introduced are indistinguishable from ones other features use, so they stay forever.
The field is three files in one directory and the diff reads as a feature. The trial deletion is rm -r trials/, then fixing whatever the compiler complains about — which is an exhaustive list rather than a hopeful one.
Moving without a big bang
The reorganisation is not worth doing as a project. It is worth doing one feature at a time, starting with the feature that is currently being changed, so that the restructuring rides along with work that has a reason to exist (The Refactoring Loop).
The step people skip is the last one. Without an enforced import rule, the new folders are a convention, and conventions lose to deadlines within about two sprints.
- 1Pick the feature you are already changing
Choose the area with a live ticket, so the move is paid for by work that was happening anyway.
fails by Starting with the biggest mess, which has the most callers and the least urgency, and stalling in review.
- 2Create the folder and move the obvious files
Move the controller, service, DTOs and validator for that feature, keeping names and behaviour identical.
fails by Renaming and restructuring in the same commit, so the diff is unreviewable and a behaviour change hides in it (What Refactoring Actually Is).
- 3Give it a public surface
One
indexfile exporting what other features may use; everything else is internal.fails by Exporting everything, which makes the folder a namespace rather than a module (Exposing Too Much).
- 4Pull shared concepts out deliberately
Where two features genuinely need the same knowledge, name it —
money/,tenancy/— and give it an owner.fails by Creating
shared/and putting the leftovers there, which recreates the problem with a new name (The Common Module). - 5Enforce the boundary
Add a lint or architecture test forbidding deep imports across features; add new violations to nothing.
fails by Adding the rule with a large allowlist that never shrinks — enforcement with a growing exception list is documentation.
- 6Leave the rest alone
Stop. The unmoved features keep working, and the next ticket moves the next one.
fails by Declaring a migration project, which produces months of conflicts and nothing a customer can see (Incremental Migration).
At no point is the whole codebase in a consistent state, and that is fine. A mixed layout where the hot features are well organised beats a uniform layout where none of them are.
How to build it
Most important first.
- Group by what changes together. If a requirement never says "change all the controllers", then
controllers/is not a unit of change and should not be a unit of structure. - Make a feature folder that contains its whole vertical slice, including its tests, and let it own its own internal layering (Vertical Slices).
- Declare a public surface per feature — one
indexfile — and forbid deep imports across features. That is the difference between a folder and a module (Designing a Module Interface). - Extract shared code only when it is genuinely shared *knowledge*, and name it for what it is:
money,tenancy,ids. If you cannot name it, it is not a shared concept (The Common Module). - Migrate incrementally, feature by feature, with new work landing in the new shape. A big-bang reorganisation is a merge-conflict machine and produces no functional change to show for it (Incremental Migration).
- Keep the framework happy at the edge: thin registration files where the framework expects them, pointing into the feature folders.
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.
- Next change: "cancellation requires a reason code". Type folders: model, migration, DTO, controller, service, validator, admin view, two tests — nine files across seven folders, and the reviewer sees nine diffs with no story. Feature folder: three files in one directory, and the diff reads as a feature.
- Next change: "delete the trial feature entirely". Type folders: a grep-driven archaeology exercise with a real chance of leaving dead code behind, because nothing marks what belonged to trials. Feature folder: delete the directory, fix the imports the compiler flags, done — which is the single most underrated benefit of the layout (Evolvability).
- What does not get cheaper: a change that genuinely is cross-cutting, such as adding a tenant id to every persistence call. Under type folders that change is unusually easy, because all the persistence code is in one place. That is the honest case for the layout and it should not be waved away — it is just a much rarer kind of change than the feature kind.
- Feature folders lose the "I know exactly where a controller goes" property, and on a large team with high turnover that property is worth real money.
- They fight the framework. Generators, autoloaders and documentation examples all assume type folders, and every deviation is a small permanent cost plus an onboarding explanation (What a Framework Charges).
- Cross-cutting technical changes genuinely get harder, and the boundary between "a feature" and "a concept two features share" has to be argued about repeatedly rather than being obvious.
What can go wrong
- The rename trap:
controllers/becomesfeatures/x/controllers/for every feature and nothing else changes, so there are more folders and the same eleven-file changes (Over-Decomposition). - Features are cut by screen rather than by domain concept, so "checkout" and "cart" both own half of pricing and neither owns the invariant (Consistency Boundaries).
- The shared folder wins anyway: under deadline, the fastest thing is
shared/, and within a year it holds the most important code in the system and has no owner (The Common Module). - The mitigation fails when the import rule is added but the feature boundary is a lie — one feature reaching into another's
internal/via a "temporary" allowlist entry that becomes permanent.
- Under type folders, dependencies run in every direction and the layout hides it:
services/importingservices/looks identical whether it is two parts of one feature or two unrelated ones. - Under feature folders, cross-feature dependencies become visible as imports between named modules, which is what makes a cycle findable (Breaking Cycles).
- The direction rule is unchanged either way — transport depends on rules, not the reverse. Feature folders make violations noticeable; type folders make them invisible (Dependency Direction).
- "So folders are the problem." Folders are inert. The problem is choosing the axis of grouping to match a technical taxonomy rather than the axis along which change arrives (Choosing the Model).
- "Feature folders mean duplication." Two features may each have their own validation file; that is not duplicated knowledge unless the same rule is written twice. The test is whether both must change together (DRY: Knowledge, Not Lines).
- "We should reorganise everything this sprint." A big-bang move produces enormous diffs, no behaviour change and weeks of conflicts. Move one feature, land it, learn (Incremental Migration).
- "Layers are dead." Layering inside a feature is useful and cheap. The claim here is only about which axis is primary (Separation of Concerns).
- shotgun-surgery
- utility-dumping-ground
Testing it, and how it ages
- Put a feature's tests inside its folder. A test that lives in a mirror-image
tests/tree is another place to keep in sync, and it moves the deletion problem rather than solving it. - Add an architecture test that fails on a deep cross-feature import. It is a few lines and it is what stops the boundary decaying to a convention (Contract Tests).
- Test the invariant inside the feature that owns it, so moving the feature moves its guarantee with it (Where Invariants Live).
- Feature folders age well because features are the unit that gets added and deleted; the layout absorbs both without reorganisation.
- The first genuinely shared concept — money, identity, tenancy — will appear within months. Extracting it into a named module at that point is healthy; pre-creating
shared/in anticipation is how the dumping ground starts (The Rule of Three). - At a certain size feature folders become the natural service boundaries, if the team ever needs them. That is the strongest structural argument for the layout and also the one most likely to be over-claimed (The Modular Monolith).
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.
- FRAMEWORK-SPECIFICRails, Django and older Spring layouts bake the type folders into generators, autoloading and every tutorial, so deviating costs configuration and a standing explanation; Go, Rust and modern .NET have module or package systems that make feature grouping the path of least resistance and give it real enforcement. The argument is identical in both, and the price of acting on it is not.
- CONTESTEDThe strongest defence of type folders is not habit: it is that they make cross-cutting technical work — adding tenancy to every query, changing every controller's error handling, auditing all persistence — dramatically cheaper, and that on large teams the uniformity means any engineer can work in any part of the codebase without learning a local layout. Teams doing platform-style work genuinely experience this as a benefit. The counter is about frequency: feature-shaped changes vastly outnumber technical sweeps in most product codebases, so optimising the layout for the rarer case is the wrong trade — but "most" is not "all", and a platform team should reach a different conclusion.
- SCALE-SPECIFICBelow roughly 30 files the whole question is noise: everything is visible at once and any layout works. It starts mattering when nobody holds the file list in their head, and it matters most when several teams own different features in the same repository.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — feature folders are the cheapest available rehearsal for a service split, because a feature that cannot be isolated in a directory will not survive being isolated behind a network.