StructureGENERALFRAMEWORK-SPECIFICSCALE-SPECIFIC

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.

The question

What should decide which folder a file goes in?

The requirement

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.

The obvious build

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.

Why it breaks

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).

How it breaks as requirements change
  • 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).
  • utils and common become 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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.
Boundaries
  • 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.

Grouped by what things are, and by what they are about
1src/ src/
2 controllers/ billing/
3 OrderController Invoice.ts
4 InvoiceController DunningPolicy.ts
5 LoyaltyController billing-api.ts <- entry point
6 ...14 more billing.test.ts
7 services/ loyalty/
8 OrderService Points.ts
9 InvoiceService EarnRules.ts
10 LoyaltyService loyalty-api.ts
11 ...14 more loyalty.test.ts
12 models/ ordering/
13 Order Order.ts
14 Invoice Basket.ts
15 LoyaltyAccount ordering-api.ts
16 ...14 more platform/ <- the shared kernel
17 utils/ http/
18 (1,900 lines) clock.ts
19 common/ ids.ts
20 (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 should the top level of the repository be named after?

What do your requirement changes have in common with each other?

Business capability

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).

Technical layer

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).

Deployment or runtime unit

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.

Team ownership

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).

Nothing — one flat package

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

CONTESTEDThe strongest defence of a shared package: for small helpers with no domain meaning — a clock wrapper, a retry helper, an id generator — insisting that each finds a "real" home produces contrived placements and arguments in review, and one clearly-named low-level package is less friction than six debates. That is right, and it is why a small kernel survives in the layout recommended here; the disagreement is about size and about whether anything carrying business knowledge is allowed in. Once rounding rules or tenancy validation live there, the package has domain reasons to change and the defence no longer applies.

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.

responsibilitiessrc/common — 2,400 lines, imported by every package`common/` as a unit, four years in
Knows
  • 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
Does
  • String and date helpers
  • HTTP client construction
  • Money rounding
  • Tenant id validation
  • CSV writing
  • Error mapping
  • Feature flag lookup
Depends on
  • The HTTP client library
  • The date library
  • Configuration
  • The feature flag service — so every package transitively depends on it too
Changes when — 7 distinct reasons
  • 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 than controllers/, 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, shared and helpers as 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.

Cost of the next change
  • 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.
What the recommended approach costs
  • 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

Failure modes
  • 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 utils without naming the concepts pushes the same files into a package called core, which is utils with better marketing.
Dependencies, and their direction
  • 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.
Misreads
  • "So never have a folder called services." Inside billing/, a services folder 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).
Smells this explains
  • utility-dumping-ground
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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).
How this design ages
  • Capabilities split as they grow: billing/ becomes billing/invoicing/ and billing/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.

Architecturemodular-monolith
Domains that do not exist yet
  • 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.