StructureGENERALDOMAIN-SPECIFICLIFETIME-SPECIFICCONTESTED

Vertical Slices

One folder per use case, containing everything that use case needs. Change locality is close to maximal, and shared concepts have nowhere obvious to live.

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 grouping by capability is good, is grouping by individual use case better?

The requirement

A team wants every change to be one folder. They propose a folder per use case — PauseSubscription/, CancelSubscription/, ResumeSubscription/ — each with its own request type, handler, validation, persistence access and tests.

The obvious build

Every use case gets a folder containing everything it needs. Nothing is shared, so nothing can break anything else, and a change is always one folder.

Why it breaks

The rules are shared whether or not the code is. Three slices each with their own copy of "a paused subscription cannot be paused again" is three chances to get it wrong and no mechanism to keep them aligned (Duplicate Knowledge).

How it breaks as requirements change
  • The rules are shared whether or not the code is. Three slices each with their own copy of "a paused subscription cannot be paused again" is three chances to get it wrong and no mechanism to keep them aligned (Duplicate Knowledge).
  • The first time a rule changes, the change is N folders — which is exactly the amplification the layout was adopted to avoid, arriving on the axis nobody priced (Change Amplification).
  • Slices multiply faster than capabilities. A product with forty use cases has forty top-level folders, and the root of the repository stops summarising anything (Over-Decomposition).
  • Shared concepts do not disappear when you delete their folder; they reappear as copied code, which is harder to find than a badly-named package (Shotgun Surgery).
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 three subscription use cases share a state machine, and that state machine is the part most likely to be got wrong (State Machines).
  • The team is eight people and the product is early, so the set of use cases is still churning.
  • Any layout has to keep the domain rules enforceable in one place, because the rules are what the auditors look at (Enforcing Invariants).
Invariants
  • A domain rule holds regardless of which slice is executing. Two slices that both enforce it must agree, and the only reliable way to make them agree is for them to share the enforcement (Where Invariants Live).
  • A slice may own its own orchestration; it does not own the meaning of the domain concepts it touches.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A slice owns the orchestration of one use case: parse the request, call the domain, persist, respond.
  • The domain owns the rules, and it is shared by every slice that touches the same concept — this is the part the naive version gets wrong.
  • Whoever adds the second slice touching a concept owns noticing that the concept now has two users and needs a home.
Boundaries
  • The slice boundary is around a use case and is genuinely good at containing orchestration, which is the part that varies per use case.
  • The domain boundary cuts across slices and must survive them, or the invariants have no single enforcement point (Consistency Boundaries).
  • The practical shape is slices for orchestration over a shared domain model — which is a capability package with slice-shaped internals, not an alternative to one (Package by Feature).

What is sliced, and what cannot be

The picture below is the whole lesson. Orchestration slices cleanly because each use case really does coordinate a different sequence. Domain rules do not slice, because a rule is a property of the concept rather than of the operation, and the moment two slices hold their own copy they are two rules that happen to agree today.

The dotted region is the part teams delete when they adopt slices enthusiastically, and re-create eighteen months later after a bug that only appeared through one endpoint.

  • Each slice owns its request type, its transport validation, its persistence call and its tests — all things that genuinely differ per use case.
  • None of them owns "a paused subscription cannot be paused again", because that sentence is about subscriptions and not about an endpoint (Where Invariants Live).
  • No slice points at another slice. That edge is the one to forbid with a tool, because it is how the top level acquires a dependency graph (Circular Dependencies).
Three slices over one domain
asks the domain to transitionown persistence callPauseSubscription sliceCancelSubscription sliceResumeSubscription sliceCopy this into each slice and the rules driftSubscription storeSubscription: states, transitions, rules
UserLLMAgentToolDataDecisionHumanGuardrail

The rule that lives in three places

The failure is not dramatic. Each slice is short, readable and correct on the day it is written, and the drift happens later, when one of them is updated and the others are not found.

What makes it expensive is that nothing in the code looks wrong afterwards. There is no missing import, no failing test and no reviewer who can see both files at once — which is why this class of bug is normally found by a customer.

Where the pause rule lives
Each slice enforces the rule itself
// PauseSubscription/handler.ts
if (sub.status === 'paused') throw new Error('already paused')
if (sub.pausesUsedThisYear >= 3) throw new Error('limit reached')
await store.update(sub.id, { status: 'paused', pausedAt: now })

// ResumeSubscription/handler.ts
if (sub.status !== 'paused') throw new Error('not paused')
await store.update(sub.id, { status: 'active', pausedAt: null })

// AdminForcePause/handler.ts   <- added later, by someone else
if (sub.status === 'paused') throw new Error('already paused')
await store.update(sub.id, { status: 'paused', pausedAt: now })
// the annual limit is missing. Nothing here looks wrong.
Slices orchestrate; the domain decides
// subscriptions/Subscription.ts   (shared by every slice)
export function pause(sub: Subscription, now: Instant): Subscription {
  if (sub.status === 'paused') throw new AlreadyPaused()
  if (sub.pausesUsedThisYear >= 3) throw new PauseLimitReached()
  return { ...sub, status: 'paused', pausedAt: now,
           pausesUsedThisYear: sub.pausesUsedThisYear + 1 }
}

// PauseSubscription/handler.ts
const updated = pause(sub, clock.now())
await store.put(updated)

// AdminForcePause/handler.ts
const updated = pause(sub, clock.now())   // cannot forget the limit
await store.put(updated)

The rule now has one address, so the fourth slice cannot omit it and a change to the limit is one edit rather than a search. The slices keep everything that genuinely differs — their request shapes, their authorisation, their responses — so the locality that made this layout attractive is intact. The cost is a shared module that every slice depends on, which becomes a coordination point and a place where an ill-considered change breaks four use cases at once (Stability and Dependency Direction).

How far to slice

SCALE-SPECIFICAt eight engineers with a dozen use cases, a flat list of slices is navigable and the shared-domain discipline is easy because everyone can see all of it. At fifty engineers with two hundred use cases, a flat slice list is unusable as a top level and the copied-rule problem becomes near-certain, so slices have to sit under capability packages and the shared domain has to be enforced by tooling rather than by everyone knowing. The layout does not change; what changes is that nothing survives on discipline alone at the larger size.

The choice is not slices versus packages; it is what the top level is named after and how much each slice is allowed to own. Four positions are common, and they differ mainly in what happens to shared concepts.

The useful question is not which is purest. It is: when the same rule is needed by a second use case, what does the layout make you do?

How much should a use-case folder own?

When a second use case needs the same rule, where does the layout put it?

Slice owns orchestration only

when The domain has real rules that must not vary by endpoint.

cost A shared domain module per capability, which becomes a coordination point and needs its own care. This is the default that works (Package by Feature).

Slice owns orchestration and persistence

when Use cases read and write genuinely different shapes, or reads and writes are separated deliberately.

cost A storage change touches every slice, and two slices writing the same rows can violate an invariant nothing in application code can see (State Ownership).

Slice owns everything, duplication accepted

when Use cases are independent by nature — an integration gateway, a set of unrelated admin operations — or the code is short-lived.

cost Rules drift silently. Only defensible when the real enforcement lives elsewhere, typically in database constraints or one transactional boundary (Enforcing Invariants).

No slices — capability packages only

when Use cases within a capability are small and share most of their orchestration.

cost Individual use cases are less independently reviewable and deletable, and a package accumulates endpoints until someone splits it (Module Granularity).

How to build it

Most important first.

  • Slice the orchestration, share the domain. That single rule resolves almost every argument about this layout.
  • Let a slice own its own request and response types, its own validation of transport-level input, and its own persistence calls — these genuinely differ per use case (Parse, Do Not Validate is the backend name for the input half).
  • Keep the domain model and its invariants in one place per capability, imported by whichever slices need it (Where Invariants Live).
  • Allow deliberate duplication between slices where the similarity is coincidental — two validations that both check a date range for unrelated reasons are not one thing (DRY: Knowledge, Not Lines).
  • Group slices under the capability they belong to, so the root of the repository still names the business rather than listing forty verbs.
  • Watch for the second slice that copies domain logic; that copy is the signal that a concept needs extracting, and it is the cheapest moment to do it (The Rule of Three).

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
  • Changing one use case costs one folder, and this is genuinely excellent: the change is trivially reviewable, trivially testable, and trivially deletable when the use case is retired.
  • Changing a domain rule costs one place if the domain is shared, and N slices if it is not. That single distinction decides whether this layout is cheap or expensive, and it is decided on day one.
  • Adding a use case costs one new folder and touches nothing existing, which is the strongest property this layout has and the reason it feels so good early.
  • Changing persistence costs every slice, because each one talks to the store directly. That is the deliberate price of removing the repository layer, and it should be paid knowingly (Cost-Aware Interfaces).
What the recommended approach costs
  • Removing the repository indirection makes each slice simpler to read and makes a storage change touch everything. That is a real trade with a real losing side.
  • A root directory listing forty use cases is less informative than one listing six capabilities, so navigability drops as the count grows (Module Granularity).
  • Slices encourage duplication, and distinguishing coincidental duplication from duplicated knowledge is a judgement the layout asks you to make constantly (DRY: Knowledge, Not Lines).

What can go wrong

Failure modes
  • Domain rules are copied into slices, drift, and produce behaviour that differs depending on which endpoint you used — the worst kind of bug, because it is correct-looking in every individual file.
  • A Shared/ folder appears next to the slices and accumulates everything, restoring the original problem (The Common Module).
  • Slices start calling each other to reuse orchestration, and the top level acquires a dependency graph nobody designed (Dependency Cycles).
  • The mitigation fails on its own terms: a team extracts the shared domain, but leaves the persistence writes in each slice, so two slices write inconsistent rows for the same concept and the invariant is violated below the level the domain can see (State Ownership).
Dependencies, and their direction
  • Slices depend on the domain; the domain depends on no slice. That direction is the whole design (Dependency Direction).
  • Slices should not depend on each other. A slice calling another slice is orchestration calling orchestration, and it is how a cycle starts (Circular Dependencies).
  • Each slice depends directly on persistence, which is a deliberate trade: it removes a repository indirection and it means a storage change touches every slice (When the Repository Is Just Indirection argues the same trade from the backend side).
Misreads
  • "Vertical slices mean no shared code." They mean no shared *orchestration*. Domain rules are shared in every version of this that works, and the ones that do not share them fail in a specific, well-documented way.
  • "This is CQRS." Separating read and write models is a different decision about consistency and data shape; you can slice without it and use it without slicing (CQRS in Architecture covers the read/write split itself).
  • "Slices remove the need for layers." Each slice has layers inside it — transport, domain call, persistence. What has gone is the top-level grouping by layer, not the layers (Package by Layer).
  • "Every change becomes one folder." Every *use-case* change does. Domain changes, cross-cutting technical changes and storage changes all still fan out, and the layout makes the last two worse (Change Amplification).
Smells this explains
  • duplicate-knowledge
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test each slice end to end through its entry point; slices are unusually easy to test this way because they have no hidden collaborators (Where a Test Must Be Real).
  • Test the shared domain rules once, directly, with no slice involved — that suite is what stops the rules drifting (What a Unit Is).
  • Add a test that fails when one slice imports another, since slice-to-slice dependencies are the specific way this layout degrades.
How this design ages
  • Early on, slices feel unambiguously right: few use cases, little shared domain, and every change in one folder.
  • The turn comes with the third or fourth slice over the same concept, when copied rules start to drift. Teams that extract the domain then do well; teams that add a Shared/ folder instead spend the next two years there.
  • Very long-lived slice layouts tend to converge on capability packages with slice-shaped internals, which is where the two ideas stop being alternatives (Package by Feature).

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 slicing by use case maximises locality for use-case change and provides no home for concepts shared between use cases is a consequence of the grouping and holds in any language.
  • DOMAIN-SPECIFICWhere use cases genuinely share little — an integration gateway, a reporting surface, a set of independent admin operations — pure slices work well and the shared-domain problem barely arises. Where a rich domain model is the product, the same layout copies rules into every slice, and the identical structure that was clean in the first system is the source of the worst class of bug in the second (Domain Modeling).
  • LIFETIME-SPECIFICFor code with a short life, or for use cases expected to be deleted rather than maintained — a campaign flow, a migration-period endpoint — a self-contained slice is ideal because deleting it is one folder and nothing else notices. The argument against slices assumes the rules inside them must stay consistent for years.
  • CONTESTEDThe strongest case for pure slices, argued by people who have shipped large systems this way: shared domain models are where coupling accumulates, and a shared model that must satisfy every slice becomes a compromise nobody wants — so it is better to let each slice own its own view of the data, keep the rules in the one or two places that actually enforce them at the database or transaction boundary, and accept duplication as the price of independence. That is a serious position and it is close to how successful event-sourced and CQRS systems are built. The disagreement is narrow: it works when the enforcement point is genuinely elsewhere, and it fails when the shared rule is enforced only in application code, because then N copies really do drift.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Architecturecqrs
Domains that do not exist yet
  • System Design — a slice is the natural unit to extract when one operation genuinely needs separate scaling or isolation, which is the main reason to draw the boundary before you need it.