StructureGENERALDOMAIN-SPECIFICSCALE-SPECIFICCONTESTED

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.

The question

If I group by capability instead of by technical role, what actually gets cheaper — and what gets worse?

The requirement

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.

The obvious build

Rename the top-level folders to capabilities, move the files, and the change locality follows automatically.

Why it breaks

Moving files does not create boundaries. If billing/ imports six classes from inside subscriptions/, the packages are folders and nothing more (Decomposition by Folder).

How it breaks as requirements change
  • Moving files does not create boundaries. If billing/ imports six classes from inside subscriptions/, 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.
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
  • 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).
Invariants
  • 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.

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

Capability packages, layers inside
1src/
2 subscriptions/
3 api.ts <- the only import other packages may use
4 Subscription.ts <- state transitions, incl. pause
5 endpoints.ts
6 store.ts <- its own persistence
7 subscriptions.test.ts
8 billing/
9 api.ts
10 Proration.ts <- owns proration; subscriptions depends on this
11 Invoice.ts
12 store.ts
13 billing.test.ts
14 identity/
15 api.ts
16 ...
17 platform/ <- kernel: mechanism, no domain content
18 http.ts clock.ts ids.ts db.ts
19
20Pause subscription -> subscriptions/ + one call into billing/api
21Swap the ORM -> three store.ts files + platform/db

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

Pause Subscription, under layer-first and capability-first
The change

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.

Layer-first: controllers / services / domain / repositories / dto / mappers / events
SubscriptionControllerSubscriptionServiceBillingServiceSubscriptionSubscriptionRepositoryPauseSubscriptionRequestSubscriptionResponseSubscriptionMapperSubscriptionPaused
testscontroller_testsubscription_service_testbilling_service_testdomain_testrepository_testmapper_test
9 modules · 6 test files

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.

Capability-first: subscriptions/ and billing/, each with its own layers and one entry point
subscriptions/Subscriptionsubscriptions/endpointssubscriptions/storebilling/Prorationbilling/api
testssubscriptions_testbilling_testpause_integration_test
5 modules · 3 test files

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.

what it cost Billing and subscriptions now have a contract, and that contract is a cross-squad negotiation whenever proration changes — where the layer-first version had both rules inside one 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

CONTESTEDThe strongest opposing view on the 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.

Capability layouts, and how they come apart
TriggerSymptomCauseResponse
A package imports another package's internal fileRefactoring inside a package breaks a distant testNo enforced entry pointOne 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 capabilitiesIt ends up in shared/, then so does everything else ambiguousNobody was required to decide on an ownerPick 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 tableA schema change breaks a package that never imported anythingThe boundary was never extended to dataOne package owns a table; others go through its entry point (State Ownership).
Each package solves pagination differentlyThree inconsistent APIs for the same ideaThe layout removed the shared technical vocabulary and nothing replaced itPut genuine mechanism in the kernel; accept divergence where the knowledge is not truly shared (DRY: Knowledge, Not Lines).
Package dependencies form a cycleNothing can be tested or reasoned about in isolationA capability boundary drawn where a change does not stopMove 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 holdIts internals become a small version of the original problemCapabilities grow; the split was right when it was madeSubdivide 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.

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

Failure modes
  • 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.
Dependencies, and their direction
  • 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).
Misreads
  • "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).
Smells this explains
  • utility-dumping-ground
  • duplicate-knowledge

Testing it, and how it ages

What to test, and at which boundary
  • 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).
How this design ages
  • 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.

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