Package by Feature
orders / payments / users, each with its own layers inside. Requirement-shaped change becomes local, and shared concepts lose their obvious home.
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.
If I group by capability instead of by technical role, what actually gets cheaper — and what gets worse?
The same "pause subscription" work, in a repository where the top level names capabilities. The question is whether the change really lands in one place, and what happens to the concepts that belong to more than one capability.
Rename the top-level folders to capabilities, move the files, and the change locality follows automatically.
Moving files does not create boundaries. If billing/ imports six classes from inside subscriptions/, the packages are folders and nothing more (Decomposition by Folder).
- Moving files does not create boundaries. If
billing/imports six classes from insidesubscriptions/, the packages are folders and nothing more (Decomposition by Folder). - The concepts that span capabilities have to be placed deliberately, and if nobody decides, they fall into a
shared/package and the layout has recreated the thing it was meant to avoid (The Common Module). - Technical uniformity is lost. Three capabilities will each solve pagination or error mapping their own way, and that duplication is a real cost, not a rounding error (Duplicate Knowledge).
- Cross-capability changes get harder, not easier: adding tracing to every inbound call is now N edits instead of one, and nothing in the layout helps.
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.
- Proration on a pause is a billing rule and a subscription rule at the same time, so at least one concept genuinely spans two packages (Consistency Boundaries).
- The team is fifteen people across three squads, and squads map roughly but not exactly to capabilities.
- A single database is shared, so package boundaries are not enforced by anything at the data layer (State Ownership).
- A capability package is entered through a named entry point; nothing outside reaches into its internals (Internal Module Contracts).
- A concept has exactly one owning package, even when several packages use it.
- Dependencies between capabilities are acyclic and few (Circular Dependencies).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each capability package owns its domain rules, its persistence, its transport surface and its tests.
- Each owns publishing a narrow entry point, and owns keeping everything else internal (Designing a Module Interface).
- A concept used by several capabilities is owned by exactly one of them, and the others depend on that one — not on a neutral third package created to avoid the decision (Stable Dependencies).
- A small technical kernel owns mechanism with no domain meaning, and owns staying small.
- The capability boundary is the primary one and it is chosen because requirements land inside it (Finding Seams).
- Technical layers still exist, inside each package, where they are cheap because the number of files is small and their relationship is already clear.
- The boundary between two capabilities is a contract as soon as more than one team is involved, and should be treated as one before it hurts (Internal Module Contracts).
The same system, grouped by subject
Layers have not disappeared; they have moved inside. What has changed is the top-level split, and therefore what a stranger sees at the root and what a change has to visit.
Look at the two annotations. The capability change is now contained. The persistence change is now repeated three times. Both of those follow from the grouping, and a fair account of this layout has to state the second as plainly as the first.
1src/2 subscriptions/3 api.ts <- the only import other packages may use4 Subscription.ts <- state transitions, incl. pause5 endpoints.ts6 store.ts <- its own persistence7 subscriptions.test.ts8 billing/9 api.ts10 Proration.ts <- owns proration; subscriptions depends on this11 Invoice.ts12 store.ts13 billing.test.ts14 identity/15 api.ts16 ...17 platform/ <- kernel: mechanism, no domain content18 http.ts clock.ts ids.ts db.ts19 20Pause subscription -> subscriptions/ + one call into billing/api21Swap the ORM -> three store.ts files + platform/dbThe api.ts files are what make this a boundary rather than a folder: everything else in a package is internal, and an import rule enforces it. Without that, billing/ will reach into subscriptions/Subscription.ts within a month and the layout will be decorative (Internal Module Contracts).
The same feature change, priced under both layouts
This is the comparison the whole argument turns on, and it is worth doing with a real capability rather than in the abstract. One requirement, two trees, and an honest account of what the winning side gave up.
The cost line matters more than the module counts. A capability layout does not make change cheap; it moves cost from the changes you make weekly to the changes you make yearly, and whether that is a good trade is a question about your merge history.
A customer can pause a subscription for up to three months. Billing prorates the current period, renewal is suspended, the state transition is validated, and an event is published for the notification system.
Nine files across seven top-level folders and six suites. No reviewer sees the capability as a unit, so review checks that each file is locally reasonable rather than that the feature is right — which is the expensive part of this layout, and it does not appear in the file count.
Two packages, and the second one only because proration is genuinely a billing rule. The pull request reads top to bottom as one capability, so review can ask whether the state machine is right rather than whether the mapper compiles.
services/ folder that either team could edit. The capability layout also loses the single-folder ORM swap: three store.ts files change instead of one repositories/ folder, and every future cross-cutting technical concern is repeated per package. Finally it costs an ongoing discipline the other layout does not need — deciding which capability owns each shared concept — and the moment that discipline lapses a shared/ package appears and takes the locality back.How this layout actually fails
shared/ row specifically: forcing an owner onto a genuinely cross-cutting concept produces a dependency that makes no domain sense — subscriptions depending on billing because proration had to live somewhere — and some experienced teams prefer an explicit, well-curated shared-kernel package to arbitrary ownership. That position is defensible when the kernel is small, reviewed strictly and free of business rules; it fails in the common case where "shared" has no owner and no admission criteria, which is why the advice here defaults the other way.The failure modes are specific and they are the same ones every time, which is unusually helpful: they can be watched for. Every one of them is a case of the boundary existing in the directory tree and not in the dependency graph.
The last row is the one to take most seriously, because it is not a mistake anyone makes deliberately. It happens by accumulation, one reasonable placement at a time.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A package imports another package's internal file | Refactoring inside a package breaks a distant test | No enforced entry point | One public module per package plus a build-failing import rule. Convention alone does not survive a deadline (What to Automate Out of Review). |
| A concept fits two capabilities | It ends up in shared/, then so does everything else ambiguous | Nobody was required to decide on an owner | Pick an owner, even imperfectly, and let the other depend on it. An arbitrary owner is far better than a neutral dumping ground (The Common Module). |
| Two packages write the same database table | A schema change breaks a package that never imported anything | The boundary was never extended to data | One package owns a table; others go through its entry point (State Ownership). |
| Each package solves pagination differently | Three inconsistent APIs for the same idea | The layout removed the shared technical vocabulary and nothing replaced it | Put genuine mechanism in the kernel; accept divergence where the knowledge is not truly shared (DRY: Knowledge, Not Lines). |
| Package dependencies form a cycle | Nothing can be tested or reasoned about in isolation | A capability boundary drawn where a change does not stop | Move the shared concept, invert a dependency, or merge the two packages — the last is the most often correct and the least often tried (Breaking Cycles). |
| A package grows past what one person can hold | Its internals become a small version of the original problem | Capabilities grow; the split was right when it was made | Subdivide along the line the merge history already shows, and keep the single entry point during the split (Module Granularity). |
How to build it
Most important first.
- Give each package a single public module and make everything else internal, using whatever your language offers — an index module, package-private visibility, an import lint rule.
- Decide where cross-capability concepts live *before* moving files, since that is the decision the layout does not make for you. Proration belongs to billing; subscriptions depends on billing rather than the reverse (Dependency Direction).
- Keep a genuinely small technical kernel for mechanism with no domain content, and defend its size actively (The Common Module).
- Accept some duplication across capabilities where the knowledge is not really shared. Two pagination helpers that differ are cheaper than one that must satisfy both (DRY: Knowledge, Not Lines).
- Enforce the boundaries with a tool, not a convention. An import rule that fails the build is worth more than a paragraph in a wiki (What to Automate Out of Review).
- Migrate one capability at a time, leaving re-exports at the old locations until callers move (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.
- A capability change costs one package and its tests, and the pull request is readable as a unit — which lowers the cost of *review*, not only of editing.
- A change to a concept owned by another capability costs a contract change between two packages and a conversation between two squads. That cost is real and is the price of having drawn the boundary.
- A cross-cutting technical change costs one edit per capability, forever. If those arrive often, this layout is the wrong one and the merge history will say so (Change Amplification).
- Splitting a capability that has grown too large is comparatively cheap, because its dependents already go through one entry point — the layout makes its own future subdivision affordable (Module Granularity).
- Cross-cutting technical change is repeated per capability, and no version of this layout avoids that.
- Every file placement requires a judgement about which capability owns the concept, and those judgements produce review debates that a technical-type layout never has.
- It gives up framework-standard navigability: an engineer who has never seen your domain cannot guess where anything is until they have learned the capabilities (Package by Layer).
What can go wrong
- A
shared/package appears within three months, and everything ambiguous goes into it, restoring the original problem with new folder names. - Capabilities import each other's internals, so the boundaries exist in the tree and not in the dependency graph.
- Two capabilities implement the same rule differently because neither owned it, and the difference is discovered by a customer (Divergent Change).
- The mitigation fails on its own terms: strict import rules are added, teams find them obstructive, and a blanket exemption is granted for one package — after which the rules describe an aspiration.
- Capability packages depend on the kernel and, sparingly, on each other in one direction only.
- The dependency graph between capabilities becomes the real architecture diagram, and it is worth drawing because it is usually not what anyone expected (Dependency Cycles).
- A shared database undercuts every package boundary, because two packages writing the same table are coupled regardless of imports (Shared-State Coupling).
- "Feature-first means no layers." It means layers are the secondary axis. Inside
billing/there is still a transport edge, a domain, and persistence — they are just not the top-level split (Package by Layer). - "Every capability should be independent." Capabilities depend on each other; the goal is that the dependencies are few, named and one-way, not that they are absent (Dependency Direction).
- "This is microservices in a monolith." It is module boundaries in one deployable, which keeps cross-cutting change cheap and refactoring possible. Turning them into services adds network failure, deployment coordination and data separation, none of which this layout requires (The Modular Monolith).
- "Shared concepts go in
shared/." That is the failure mode, not the design. A shared concept has an owner; finding out which capability owns it is the work the layout is asking you to do (The Common Module).
- utility-dumping-ground
- duplicate-knowledge
Testing it, and how it ages
- Test each package through its entry point, so tests survive internal restructuring (What a Unit Is).
- An import test per package: nothing outside may import anything but the entry point. This is the single highest-value check in this layout (Contract Tests).
- One end-to-end test per capability that exercises transport through persistence, since the layout no longer gives you a layer to integration-test as a slab (Where a Test Must Be Real).
- Capabilities subdivide as they grow, and the merge history tells you when: two halves of a package that stop appearing in the same commits are two packages.
- The kernel grows unless defended, and everything that enters it becomes expensive to change because everything depends on it (Stability and Dependency Direction).
- The layout is stressed when the business reorganises its own vocabulary — a capability that gets renamed or split in the business eventually has to be renamed or split in the code (Ubiquitous Language).
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 grouping by capability makes requirement-shaped change local and technical change distributed is a structural consequence of the grouping, and it holds in any language with directories.
- DOMAIN-SPECIFICThis pays when capabilities are genuinely distinguishable and change independently — billing, catalog, identity in a commerce system. In a system whose "capabilities" are all views of one tightly-coupled model, the split is artificial: every change crosses it, and the layout has added boundaries without adding locality (Over-Decomposition).
- SCALE-SPECIFICAt five engineers this mostly buys readable pull requests, which is nice but modest. At fifty, it buys something larger: capability packages let squads work without touching each other's files, which turns a merge-conflict and review-queue problem into an occasional contract negotiation. The same layout is a preference at one size and an organisational necessity at the other (Code Ownership).
- CONTESTEDThe strongest opposing view: feature packaging trades one shared technical vocabulary for N per-package conventions, so a codebase drifts into inconsistency where each package solves pagination, validation and error mapping differently, and an engineer moving between packages relearns everything. Proponents of layer-first point out — correctly — that this is exactly what happens without strong technical leadership, and that the "shared concept has an owner" discipline is much harder in practice than on a slide. The counter is that per-package inconsistency is visible and fixable, while a capability with no home is invisible until two teams have implemented it twice.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — capability packages are the shape you would split into services if you ever had to, which is the main practical argument for drawing them before you need them rather than during a migration.