Package Design
A package is a claim about what changes together. Grouping by domain, feature or capability makes that claim; grouping only by technical type makes no claim at all.
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.
What should decide which folder a file goes in?
A new engineer asks where to put a class that calculates loyalty points. The honest answer is that it could reasonably go in five places, and three different people would give three different answers.
Group by what things are. Controllers with controllers, services with services, models with models, utilities in utils. It is consistent, it is what the framework generates, and nobody has to make a decision.
It optimises for a question nobody asks. Engineers do not look for "all the controllers"; they look for "everything about loyalty points", and that is spread across six folders (Shotgun Surgery).
- It optimises for a question nobody asks. Engineers do not look for "all the controllers"; they look for "everything about loyalty points", and that is spread across six folders (Shotgun Surgery).
- The layout makes no claim, so it cannot be wrong — and it cannot be informative either. A folder of forty services tells you nothing about what the system does (Decomposition by Folder).
utilsandcommonbecome the answer for anything that does not obviously fit, and since nothing obviously fits a technical-type scheme, they grow without limit and acquire every reason to change (The Utility Dumping Ground).- It survives because it is never wrong at file-creation time. Each individual placement decision is easy; the cost lands entirely on the person who later has to change a behaviour rather than a file.
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 is four years old and already has a layout; any new scheme has to be reachable incrementally from the current one (Incremental Migration).
- Twelve engineers across three teams navigate this daily, so the layout has to be guessable without asking.
- The build tooling caches by directory, so package boundaries have a measurable effect on how much is rebuilt and re-tested on every change.
- One concept has one home. If loyalty points are computed in two packages, the layout has stopped being a claim about anything (Duplicate Knowledge).
- A package's name predicts its contents well enough that a stranger can guess where something lives and be right most of the time.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- A package owns a coherent piece of the domain and everything needed to reason about it.
- The top-level split owns communicating what the system does; if the root of the repository does not name capabilities, it is documenting your framework instead of your product (Naming and Domain Language).
- Genuinely shared technical mechanism — logging setup, HTTP plumbing, serialisation — owns being small, named for what it does, and depended on by everything without depending on anything.
- Put the boundary where a likely change stops. That is the same test as everywhere else in this domain, applied to directories (Finding Seams).
- A package boundary should also be a knowledge boundary: things inside may know about each other, things outside go through a named entry point (Internal Module Contracts).
- Technical-type folders are legitimate *inside* a capability package, where they organise a small number of files whose relationship is already clear.
Two trees, one system
The same application, laid out twice. Nothing about the code differs — the same classes, the same tests, the same behaviour. What differs is what you can see from the root, and what a change has to visit.
Read the first tree and try to say what the product does. Then read the second. That difference is not aesthetic: it is the same information a stranger uses to decide where a change goes, and the same information that decides whether a change is contained.
1src/ src/2 controllers/ billing/3 OrderController Invoice.ts4 InvoiceController DunningPolicy.ts5 LoyaltyController billing-api.ts <- entry point6 ...14 more billing.test.ts7 services/ loyalty/8 OrderService Points.ts9 InvoiceService EarnRules.ts10 LoyaltyService loyalty-api.ts11 ...14 more loyalty.test.ts12 models/ ordering/13 Order Order.ts14 Invoice Basket.ts15 LoyaltyAccount ordering-api.ts16 ...14 more platform/ <- the shared kernel17 utils/ http/18 (1,900 lines) clock.ts19 common/ ids.ts20 (2,400 lines)The left tree answers "what kind of thing is this file". The right tree answers "what is this system for", and puts everything a loyalty change touches in one place. Note that the right tree still has a technical grouping — platform/http/ — because a shared kernel of genuine mechanism is a real thing; what it does not have is a top level made of it, or a folder named for how code is accessed rather than what it is (The Common Module).
Choosing the axis you group on
There are more than two options, and the right one depends on what your requirements are shaped like. A system whose tickets say "add a payment method" and one whose tickets say "make everything faster" are not asking for the same layout.
The failure is not choosing wrongly; it is not choosing at all, and inheriting whatever the framework generator produced in 2011.
What do your requirement changes have in common with each other?
when Tickets name parts of the business — billing, loyalty, catalog — and each one mostly touches one of them.
cost Cross-cutting technical change now visits every capability, and shared concepts need a deliberate home rather than falling into a folder by default (Package by Feature).
when The dominant changes are technical and uniform — swapping a persistence library, changing the transport, adding tracing — and the domain is genuinely thin (Transaction Script).
cost Every requirement-shaped change tours every layer, and the layout tells a reader nothing about the product (Package by Layer).
when Parts of the system genuinely ship separately — a web app, a worker fleet, a CLI — and share a core.
cost It is a real constraint and a poor primary axis: it says where code runs, not what changes together, so within each unit you still need one of the other two.
when The organisation is large enough that "who reviews this" is the dominant daily question.
cost Layout now tracks the org chart, so the next reorganisation invalidates it. Better expressed as ownership metadata over a domain layout than as the layout itself (Code Ownership).
when Under a few thousand lines with one or two people.
cost Genuinely correct at that size and for longer than most engineers are comfortable with. It stops working when a stranger cannot read the whole thing in a sitting (When Design Does Not Pay).
What `common/` actually is
Every technical-type layout grows a package with a name like common, shared, utils or core, and it is worth looking at one as though it were a class, because it behaves like the worst one you have ever seen.
The point is not that the individual files are bad. Most of them are fine. The point is that the package has no coherent reason to change, so it has all of them at once, and since everything depends on it, all of them are expensive.
- — Date formatting rules for three locales
- — The retry policy for the payment gateway
- — What a valid tenant id looks like
- — How prices are rounded
- — The company's CSV dialect
- — Which HTTP status maps to which internal error
- — String and date helpers
- — HTTP client construction
- — Money rounding
- — Tenant id validation
- — CSV writing
- — Error mapping
- — Feature flag lookup
- — The HTTP client library
- — The date library
- — Configuration
- — The feature flag service — so every package transitively depends on it too
- — Rounding rules change
- — A locale is added
- — The payment gateway changes its retry guidance
- — The tenancy model changes
- — The CSV dialect changes
- — The flag service client is upgraded
- — Any HTTP behaviour changes
Seven unrelated reasons to change in a package that everything imports, which makes every one of those changes a full-repository rebuild and a full regression. The fix is not to split it into seven folders under common/ — that keeps the dependency and renames the problem. It is to send each concept home: rounding goes to billing, tenant id validation goes to identity, CSV goes to whichever capability exports, and what is left over — clock, ids, HTTP construction — is a genuinely small kernel with one reason to change, which is the only version of this package that is defensible (The Common Module).
How to build it
Most important first.
- Name the top level after the business, not the framework:
billing/,catalog/,identity/rather thancontrollers/,services/,models/(Package by Feature). - Let each package have its own internal shape. Uniformity below the top level is worth much less than people assume, and enforcing it drags files into folders they do not belong in.
- Give each package one entry point and treat everything else as internal, so that "who depends on this" is answerable (Designing a Module Interface).
- Refuse
utils,common,sharedandhelpersas destinations. A file that has no home is telling you a concept has not been named yet; naming it is the work (The Common Module). - Move things when the evidence says so. Merge history that shows two packages always changing together is evidence they are one package (Change Amplification).
- Do the migration in slices, one capability at a time, with the old locations re-exporting until callers have moved.
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 a technical-type layout, a requirement about loyalty points costs six folders and a search, every time, and the cost does not fall as the team learns the codebase — it is structural, not knowledge.
- Under a capability layout, the same requirement costs one package plus whatever contracts it has with others. The next loyalty requirement costs the same, which is the property being bought.
- What gets more expensive: a cross-cutting technical change — adding tracing, changing the serialisation library — now touches every capability instead of one layer. That is the real bill and it is due every time such a change arrives.
- Moving files later costs merge conflicts across every open branch, plus a period where half the team cannot find anything. That cost is proportional to team size, which is why layout decisions get harder to revisit as you grow.
- A capability layout requires judgement on every file placement, where a technical-type layout requires none — that friction is real and is the honest reason teams do not do it.
- It makes cross-cutting technical change more expensive, and there is no version of it that does not.
- Framework conventions frequently assume technical-type folders, so going against them costs configuration and occasionally fights the tooling (What a Framework Charges).
What can go wrong
- The new layout is applied to new code only, so the repository has two schemes and a stranger can guess neither.
- Packages are drawn from the org chart rather than the domain, so the next reorganisation invalidates the layout (Code Ownership).
- A "domain" package is created that is really a technical layer with a business-sounding name, and nothing has changed except the label.
- The mitigation fails on its own terms: banning
utilswithout naming the concepts pushes the same files into a package calledcore, which isutilswith better marketing.
- Capability packages depend on a small shared kernel of technical mechanism; the kernel depends on nothing of yours (Stable Dependencies).
- Capability-to-capability dependencies should be few, named and one-way. Two capabilities that depend on each other are one capability wearing two names (Circular Dependencies).
- The layout constrains the build graph, so package structure and build time are the same decision viewed twice.
- "So never have a folder called
services." Insidebilling/, aservicesfolder holding four files whose relationship is obvious is fine. The problem is a technical-type split at the *top* level, where it replaces the claim about what changes together (Package by Layer). - "Package structure is cosmetic." It determines where changes land, what the build rebuilds, and what a stranger can find. It is one of the few design decisions every engineer touches every day.
- "We should mirror our microservices." Package layout and deployment layout answer different questions, and copying one into the other imports its constraints for free (The Modular Monolith).
- "Shared code goes in
shared." A name that describes an access pattern rather than a concept guarantees the package acquires unrelated reasons to change. Name the concept (The Common Module).
- utility-dumping-ground
- shotgun-surgery
Testing it, and how it ages
- Mirror the package structure in the test tree, so that a capability's tests are as findable as its code (Testing as Design Feedback).
- Add an architecture test that fails when a capability imports another capability's internals rather than its entry point — a cheap, permanent enforcement of the thing reviews forget (Internal Module Contracts).
- Test the shared kernel as a library, with no knowledge of any capability. If a kernel test needs a domain concept, the kernel has acquired one (What a Unit Is).
- Capabilities split as they grow:
billing/becomesbilling/invoicing/andbilling/dunning/when the two stop changing together, and that evidence shows up in merge history first. - The shared kernel grows unless someone defends it, and everything that lands in it becomes hard to change because everything depends on it (Stability and Dependency Direction).
- The layout stops being right when most changes cross package boundaries; at that point the top-level split is not where requirements land, and the split has to move (Change Amplification).
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.
- GENERALThat the top-level split decides where changes land, and therefore how many folders a requirement tours, holds in any language with a module or directory system — only the enforcement mechanism differs.
- FRAMEWORK-SPECIFICRails, Django and most MVC scaffolds generate a technical-type tree and their tooling — generators, autoloading, convention-based lookup — assumes it. Going against that costs configuration and loses some framework conveniences, so the same capability layout that is nearly free in a Go or plain-TypeScript codebase carries a real tax in a convention-heavy framework.
- SCALE-SPECIFICAt three engineers and forty files, layout barely matters: everyone has read everything and a search finds anything. At forty engineers the layout is the primary navigation mechanism and the primary way work is partitioned without conflict, so effort that would be waste at the first size is clearly repaid at the second.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — module systems differ in what a package boundary can actually enforce, and in a language where every symbol is reachable the layout is documentation rather than a constraint.