Vertical slice architecture, and the anti-pattern catalog
What it is
Vertical slice architecture organises code by feature rather than by technical layer. Everything a single use case needs, its request shape, validation, business rule, persistence and response, lives together, and slices are allowed to differ from one another.
LAYERED (the default) VERTICAL SLICES
src/ src/
controllers/ Features/
OrderController Orders/
RefundController PlaceOrder/
...(40 more) PlaceOrder.cs (command)
services/ PlaceOrderHandler.cs (logic)
OrderService PlaceOrderValidator.cs
RefundService PlaceOrderEndpoint.cs
...(40 more) CancelOrder/
repositories/ ...
OrderRepository Refunds/
...(40 more) IssueRefund/
models/ ...
The claim: a change to one feature should touch one directory, not four, and the coupling that matters is the coupling within a use case, not the similarity between use cases.
The anti-pattern catalog is the companion: the named failures that recur often enough to have names, so you can identify one in a code review or a design discussion in a sentence rather than a paragraph.
What this is confused with: vertical slices and microservices. Slices are a code-organisation choice inside one deployable. You can, and usually should, have vertical slices inside a modular monolith, and the slice boundaries are a useful preview of where a service boundary might later go.
Also confused: an anti-pattern and a mistake. An anti-pattern is a solution that looks correct, gets adopted deliberately, and produces a worse outcome than doing nothing. "They forgot to add an index" is a bug; "they put every entity behind a generic repository interface" is an anti-pattern, because it was a decision made for a reason.
The problem it solves
Layered architecture optimises for a similarity that does not predict change.
Adding one field to one feature, in a layered codebase:
1. Controllers/OrderController.cs (request DTO)
2. Models/OrderRequest.cs
3. Services/IOrderService.cs (interface)
4. Services/OrderService.cs (implementation)
5. Repositories/IOrderRepository.cs
6. Repositories/OrderRepository.cs
7. Models/OrderEntity.cs
8. Mappers/OrderMapper.cs
Eight files, in eight directories, for one field. The
compilation unit is scattered, the pull request is unreadable,
and every file also contains 40 other features' code, so a
merge conflict is likely for reasons unrelated to your change.
And the second, subtler failure: the layers force uniformity where none is warranted.
A codebase where every read goes through a repository, and:
GET /orders/{id} genuinely needs the repository
GET /orders/search needs a hand-written query with
joins and a full-text predicate,
so it fights the abstraction
GET /orders/report needs a 200-line aggregate query
that no repository interface can
express
The last two end up either (a) mangled into the repository as
methods nobody else uses, or (b) bypassing it, which everyone
agrees is a violation, so it happens quietly.
A vertical slice lets the simple read use an ORM, the search
use a hand-written query, and the report use raw SQL, WITHOUT
any of them being an exception to a rule.
"Some slices need less abstraction than others" is the core insight, and layering cannot express it because a layer is a horizontal commitment applied to everything.
Mechanics
What a slice contains
Features/Orders/PlaceOrder/
PlaceOrder.cs the request/command shape
PlaceOrderValidator.cs validation for THIS use case
PlaceOrderHandler.cs the behaviour, including persistence
PlaceOrderResponse.cs what the caller gets back
PlaceOrderEndpoint.cs the HTTP (or gRPC, or queue) binding
PlaceOrderTests.cs tests, next to the thing tested
// One file, one use case, top to bottom. The reader does not
// have to hold four directories in their head.
public sealed record PlaceOrder(Guid CustomerId, IReadOnlyList<Line> Lines)
: IRequest<PlaceOrderResponse>;
internal sealed class PlaceOrderHandler : IRequestHandler<PlaceOrder, PlaceOrderResponse>
{
private readonly AppDbContext _db; // no repository interface:
private readonly IPricingClient _pricing; // the ORM IS the abstraction
private readonly IClock _clock; // injected because it is
// non-deterministic
public async Task<PlaceOrderResponse> Handle(PlaceOrder cmd, CancellationToken ct)
{
var quote = await _pricing.Quote(cmd.Lines, ct);
// Domain rule lives with the use case that enforces it, unless
// it is shared, in which case it lives in the domain model.
var order = Order.Place(cmd.CustomerId, cmd.Lines, quote, _clock.UtcNow);
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
return PlaceOrderResponse.From(order);
}
}
What is deliberately absent: an IOrderRepository, an IOrderService, and a mapper layer. Each
of those exists in a layered codebase to serve all forty features, and here the slice serves one.
What still lives outside the slices
Vertical slices are not "no shared code", and the boundary is the interesting part.
SHARED, and it should be:
- the domain model and its invariants (an Order aggregate
that enforces its own rules belongs in Domain/, because
the invariant is not per-use-case)
- cross-cutting infrastructure: auth, logging, tracing,
transactions, outbox
- genuinely reused value objects: Money, EmailAddress
- the database schema
NOT SHARED, and this is the discipline:
- request and response shapes. Two use cases returning
"an order" almost always want different fields, and a
shared OrderDto is how they become coupled.
- validation. The rules for placing an order and for
amending one differ.
- queries. Let each read use whatever it needs.
The most common failure of a slice codebase is duplication panic: three slices each have a 15-line query, someone extracts a shared query object, and within two quarters it has six optional parameters and three boolean flags. Duplication between slices is usually cheaper than the coupling that removes it, and the test is whether the two pieces of code will change for the same reason.
The trade honestly stated
VERTICAL SLICES WIN LAYERS WIN
--------------------------------------------------------------
a change touches one directory a mechanical change across
all features (adding a
tracing header, swapping an
ORM) touches one place
each slice can use the right there is one obvious place
amount of abstraction for everything, which is
easier for newcomers
deleting a feature deletes a enforcing a rule everywhere
directory is easy: it is one layer
merge conflicts are rare, since fewer total types
teams work in different
directories
slices preview service boundaries a shared abstraction genuinely
used 40 times pays for itself
The strongest argument for slices is deletion: in a layered codebase, removing a feature means finding its pieces in eight directories and hoping nothing else used them, and in practice features are not deleted, they accumulate.
The anti-pattern catalog
Structural
BIG BALL OF MUD
no discernible structure; everything reaches everything.
Tell: any file can import any other, and the dependency graph
has cycles.
Fix: introduce seams and enforce them mechanically (module
boundaries, an architecture test), not by convention.
DISTRIBUTED MONOLITH
services that must be deployed together, share a database, or
break each other on any change. All the operational cost of
microservices, none of the independence.
Tell: a release train; "we need to deploy A before B";
a shared schema.
Fix: fix the coupling, or merge the services. A modular
monolith is a legitimate destination.
ENTITY SERVICE / NANOSERVICE
a service per noun: OrderService, CustomerService,
AddressService. Every use case becomes a distributed
transaction across four of them.
Tell: a single user action fans out to 6+ services, all owned
by the same team.
Fix: services are drawn around business capabilities and team
boundaries, not around database tables.
GOD OBJECT / GOD SERVICE
one class or service that everything depends on.
Tell: it is the file with the most changes and the most
authors, and every incident involves it.
Fix: split by reason-to-change, not by size.
THE ANAEMIC DOMAIN MODEL
data classes with getters and setters, and all behaviour in
services. Debated, and legitimately so.
It is an anti-pattern when the same invariant is enforced in
five services and missed in the sixth.
It is FINE when the domain is genuinely a data pipeline with
no invariants worth protecting.
Abstraction
SPECULATIVE GENERALITY
an abstraction with one implementation, built for a second
that never arrives.
Tell: an interface whose only implementor shares its name,
a plugin system with one plugin, a config option nobody has
changed.
Fix: delete it. Two implementations justify an interface;
one does not.
THE GENERIC REPOSITORY
IRepository<T> with Add/Get/Update/Delete/Query over every
entity. It reimplements the ORM badly and leaks IQueryable,
so it abstracts nothing.
Tell: every real query bypasses it or adds a bespoke method.
Fix: use the ORM directly, and write a specific repository
only where there is genuinely a domain-level collection
abstraction. (See the repository page for the full argument.)
INNER-PLATFORM EFFECT
building a configurable system so general that it becomes a
worse version of the platform underneath. A rules engine that
is a slow interpreter; a schema stored in rows.
Tell: "we can express any workflow" and only three exist.
Fix: write the three.
PRIMITIVE OBSESSION
strings and ints for everything, so a currency, a user id and
a postcode are all `string` and can be swapped by accident.
Fix: value objects at the boundaries that matter (Money,
UserId), not everywhere.
Process and organisational
THE SECOND-SYSTEM EFFECT
the rewrite that includes every feature the first system
lacked, ships late, and is worse.
Fix: strangle incrementally. (See legacy modernisation.)
RESUME-DRIVEN DEVELOPMENT
technology chosen for its novelty or for the engineer's
career rather than the problem.
Tell: the ADR's "alternatives considered" section is thin, or
the justification is a property the problem does not need.
Fix: an ADR that must state what the alternative costs.
GOLDEN HAMMER
the pattern that worked last time, applied everywhere.
Event sourcing for a CRUD admin screen; Kubernetes for one
cron job.
Fix: name the force that justifies it. If you cannot, do not.
BIKESHEDDING
effort proportional to how easy something is to have an
opinion about. Two hours on a variable name, five minutes on
the consistency model.
Fix: automate the trivial (formatters, linters) so it cannot
be discussed.
CARGO CULTING
copying a large company's architecture without their
constraints. Their solution is shaped by a scale, an
organisation size and a failure history you do not have.
Fix: read what problem it solved and check you have that
problem.
Data and integration
SHARED DATABASE INTEGRATION
two services reading and writing each other's tables. There
is no contract, so any schema change is a coordination
problem across teams.
Tell: you cannot alter a column without asking three teams.
Fix: one writer per table; everyone else reads through an API
or a published event stream.
DUAL WRITE
writing to a database and publishing an event as two separate
operations. The two diverge on any partial failure, and they
will.
Fix: the transactional outbox, or change data capture.
CHATTY INTEGRATION / N+1 ACROSS THE NETWORK
a loop making one call per item. Tolerable in-process,
fatal across a network.
Fix: batch endpoints, or denormalise.
THE MAGIC PUSHBUTTON / SMART UI
business logic in the controller or the UI, so it cannot be
tested or reused, and the second client reimplements it.
Fix: push the rule into the domain and let both clients call
it.
Concurrency and operations
UNBOUNDED ANYTHING
unbounded queue, unbounded retry, unbounded cache, unbounded
result set. Each converts a latency problem into a memory
problem or an outage.
Fix: a limit on everything, and a defined behaviour at the
limit.
RETRY WITHOUT BUDGET OR JITTER
amplifies load precisely when capacity is shortest, and
re-synchronises into a second spike.
Fix: retry budget, full jitter, circuit breaker.
THE DISTRIBUTED LOCK AS A CORRECTNESS MECHANISM
a lock in Redis used to guarantee mutual exclusion for
something that matters. Under GC pauses, network partitions
and clock skew it does not.
Fix: fencing tokens, or make the operation idempotent so the
lock is only an optimisation.
LOG-AND-RETHROW
every layer catches, logs, and rethrows, producing five stack
traces for one error and no additional information.
Fix: handle it or let it propagate. Log once, at the boundary
that decides what to do.
Using the catalog well:
The value of a name is that it compresses an argument.
"That's a distributed monolith" is one sentence and it invokes
a shared understanding of the specific cost.
The failure is using a name INSTEAD of an argument. If someone
says "that's speculative generality" and the response is "no it
isn't", the label has done no work and the discussion has to
happen anyway.
So: name it, then say the concrete consequence in this
codebase. The name is the index, not the argument.
A worked example: a codebase with four of them at once
An order-management service, four years old, 180,000 lines, nine engineers, and a stated problem of "velocity has dropped and we do not know why."
The measurement, before any opinion:
Median files changed per pull request: 11
Median directories touched per pull request: 6
Median PR review latency: 38 h
Merge conflicts per week: 14
Time to add a field to an existing feature
(measured over 8 instances): 2.5 days
Interfaces with exactly one implementation: 147
Features deleted in 4 years: 0
Eleven files and six directories for a median change, and zero features ever deleted, which is the signature of layering plus speculative generality.
The anti-patterns identified, named, and costed:
1. GENERIC REPOSITORY.
IRepository<T> over 34 entities, plus 61 bespoke methods
added to it over four years because the generic interface
could not express real queries. Nine places bypassed it
entirely with raw SQL, each with a comment apologising.
Cost: an abstraction that added a layer, prevented nothing,
and had 61 exceptions.
2. SPECULATIVE GENERALITY.
147 single-implementation interfaces. Sampled 20: 3 existed
for testing (legitimate, and could have been achieved with a
fake), 17 existed "in case we swap it out". None had ever
been swapped.
3. SHARED DATABASE INTEGRATION.
The fulfilment service read the orders schema directly,
including two columns whose meaning had changed. Any orders
migration required a coordination meeting.
4. DUAL WRITE.
Order placement wrote to Postgres and published to Kafka in
the same method, with no transaction spanning them.
Measured over 90 days of logs: 214 orders existed in the
database with no corresponding event, and 31 events existed
with no order (a retry after a failed commit).
The 214-and-31 number is the one that turned "we should fix the dual write" into a funded piece of work, because it converted a pattern name into a data-integrity defect with a count.
The restructure, over two quarters:
STEP 1: kill the abstractions (3 weeks)
- deleted the generic repository; slices use the ORM
directly, and three genuine domain repositories were kept
where an aggregate collection abstraction was real
- deleted 128 of the 147 single-implementation interfaces
(kept 19, all with a stated reason in a comment)
- net: -11,000 lines
STEP 2: outbox (2 weeks)
- order placement writes the order and the outbox row in one
transaction; a relay publishes
- divergences since: 0
STEP 3: vertical slices, feature by feature (1 quarter)
- new features written as slices from day one
- existing features migrated when touched, not in a big bang
- after a quarter: 41 of 63 features migrated, and the
remaining 22 were ones nobody had needed to change, which
is itself information
STEP 4: the shared database (ongoing)
- fulfilment moved to a published event stream plus a
read-only API
- the orders schema became owned by one service
Migrating slices only when a feature is touched is the sequencing decision, because it makes the restructure free (it rides on work that was happening anyway) and it self-prioritises onto the code that actually changes.
Measured after two quarters:
before after
median files per PR 11 4
median directories per PR 6 1
median PR review latency 38 h 9 h
merge conflicts per week 14 3
time to add a field 2.5 days 0.5 days
single-implementation interfaces 147 19
lines of code 180,000 162,000
order/event divergences (90d) 245 0
features deleted 0 7
Seven features deleted in two quarters after four years of zero is the result that surprised the team, and the mechanism is simple: when a feature is one directory, deleting it is an obvious, reviewable, low-risk pull request, and when it is spread across eight directories nobody is confident enough to try.
Two things that went wrong:
1. DUPLICATION PANIC, six weeks in. Three slices had similar
15-line queries. Someone extracted a shared QueryBuilder.
Within a month it had 6 optional parameters and 2 boolean
flags, and a change to one slice's query broke another's.
Reverted, and a rule was written into the team's ADR:
"duplication between slices requires a stated reason to
remove, namely that the two will change for the same
reason."
2. AN ARCHITECTURE TEST WAS ADDED TOO EARLY, forbidding any
slice from referencing another slice's types. It was correct
in intent and it blocked a legitimate case (a slice
composing another slice's handler), so it was disabled
within two weeks, which meant it enforced nothing at all
afterwards.
Rewritten narrowly: slices may not reference another slice's
REQUEST/RESPONSE types, which is the coupling that actually
hurts. That rule survived.
A rule that gets disabled is worse than no rule, because everyone now knows the enforcement is theatre, and the narrow rule that survives is worth more than the broad one that does not.
Production evidence
Vertical slice architecture was named and popularised by Jimmy Bogard, whose stated argument is that layers impose a uniform abstraction cost on use cases with wildly different needs, and that coupling within a feature matters more than similarity across features.
Package-by-feature over package-by-layer is a long-standing position in the Java community with the same reasoning, and it is the organisation used by most modern framework scaffolding that postdates the layered convention.
The distributed monolith is documented extensively in microservices literature, including Sam Newman's Building Microservices and Monolith to Microservices, with the release-train and shared-database tells named explicitly.
The generic repository critique is argued in detail by Bogard ("Repository is Dead") and others, on
the grounds that an ORM's DbSet/Session already is the repository and that a generic wrapper leaks
IQueryable and therefore abstracts nothing.
The transactional outbox as the fix for dual write is documented in Chris Richardson's microservices patterns catalogue and implemented by Debezium, and the divergence it prevents is observable in any system that writes to a database and a broker without one.
Martin Fowler's writing covers the anaemic domain model debate and speculative generality; the "AntiPatterns" book (Brown et al., 1998) is the original catalogue that named the big ball of mud, the golden hammer and several others still in use.
Kyle Kingsbury's and Martin Kleppmann's analyses of distributed locks establish why a Redis lock is not a correctness mechanism without fencing tokens, which is the basis for that entry.
The debate
Are vertical slices better than layers? For application code with many independent use cases, yes. For a library, a framework, or a system with genuinely uniform operations, layers are correct, because the shared abstraction is used every time and pays for itself. The honest cost of slices is cross-cutting mechanical changes, which touch every slice instead of one layer, and the mitigation is that genuinely cross-cutting concerns (auth, tracing, transactions) stay in shared infrastructure.
Is duplication between slices acceptable? Usually, and this is the hardest part of the discipline to hold. The test is whether the two pieces will change for the same reason, not whether they look alike. The failure mode is well documented in the worked example: an extracted shared query object accumulated six optional parameters and two flags within a month, which is more coupling than the duplication it removed.
Is the anaemic domain model an anti-pattern? Contested, and the useful resolution is conditional. It is an anti-pattern when an invariant is enforced in five services and missed in the sixth, because the invariant has no home. It is fine when the domain genuinely has no invariants worth protecting, which is true of a large amount of pipeline and reporting code, and insisting on rich models there is its own anti-pattern.
Should anti-patterns be enforced by tooling? Where they are mechanically detectable, yes: architecture tests for module boundaries, linters for unbounded operations, a dependency-cycle check. Where they require judgment, no, and the worked example's failed architecture test is the lesson: a rule broad enough to catch a real case will also catch a legitimate one, get disabled, and then enforce nothing.
Is naming an anti-pattern useful or lazy? Useful as an index and lazy as an argument. "That's a distributed monolith" compresses a shared understanding into three words, and it must be followed by the concrete consequence in this codebase, or the discussion just becomes a disagreement about a label.
Do slices replace domain-driven design? No, they are orthogonal. A slice is a code-organisation choice; a bounded context is a model boundary, and the two combine well: slices inside a bounded context, with the domain model shared among the slices that belong to it.
Follow-up Q&A
"What is vertical slice architecture and what does it fix?"
Organising code by feature rather than by technical layer, so everything one use case needs, request shape, validation, business rule, persistence and response, lives in one directory. It fixes two things. A change touches one place instead of eight, which in one codebase took the median pull request from 11 files across 6 directories to 4 files in 1. And it lets each use case choose its own amount of abstraction, so a simple read can use the ORM, a search can use a hand-written query and a report can use raw SQL without any of them being an exception to a rule, which a horizontal layer cannot express.
"What still gets shared in a slice architecture?"
The domain model and its invariants, because an invariant is not per-use-case; cross-cutting infrastructure like auth, tracing, transactions and the outbox; genuinely reused value objects; and the database schema. What does not get shared is request and response shapes, validation, and queries. Sharing a response DTO between two use cases is how they become coupled, since two features returning "an order" almost always want different fields, and a change for one becomes a change for both.
"When is duplication between slices the right answer?"
When the two pieces of code will not change for the same reason, which is most of the time even when they look alike. The failure is well documented: in one codebase three slices had similar fifteen-line queries, someone extracted a shared query builder, and within a month it had six optional parameters and two boolean flags and a change for one slice broke another. The team wrote a rule that removing duplication between slices requires a stated reason, namely a shared reason to change, which inverts the usual default deliberately.
"What is the difference between an anti-pattern and a bug?"
An anti-pattern is a solution that looks correct, is adopted deliberately for a reason, and produces a worse outcome than doing nothing. A missing index is a bug. Putting every entity behind a generic repository is an anti-pattern, because someone decided to, for a stated benefit, and the benefit did not arrive: in one codebase that interface had accumulated 61 bespoke methods it could not express generically and nine documented bypasses.
"Name the anti-patterns you would look for in a service architecture review."
Distributed monolith, meaning services that must deploy together or share a database, with the tells being a release train and a schema nobody can alter alone. Entity services, one per noun, so every use case is a distributed transaction across four of them. Shared database integration, where there is no contract so every migration is a cross-team coordination problem. Dual write, writing to a database and a broker as two operations, which diverges on any partial failure. Chatty integration, an N+1 across the network. And unbounded anything: queues, retries, caches, result sets.
"How do you use the catalog without it becoming name-calling?"
The name is the index, not the argument. Say the name to invoke the shared understanding, then immediately state the concrete consequence in this codebase with a number if one exists. "That's a dual write" is a label someone can simply disagree with; "that's a dual write, and over ninety days of logs we have 214 orders with no event and 31 events with no order" converts a pattern discussion into a data-integrity defect with a count, which is what actually gets it funded.
Common misconceptions
"Vertical slices mean no shared code." The domain model, cross-cutting infrastructure and value objects stay shared. What is not shared is request and response shapes, validation, and queries.
"Slices are microservices." They are a code-organisation choice inside one deployable, and they work particularly well inside a modular monolith.
"Duplication is always bad." Between slices, the coupling introduced by removing it is usually worse. The test is a shared reason to change, not visual similarity.
"An interface makes code testable." A fake implementation makes code testable. An interface with one implementor, created for a second that never arrives, is speculative generality.
"Naming the anti-pattern settles the argument." It compresses the argument. It has to be followed by the specific consequence here.
"Architecture tests should be strict." A rule broad enough to catch every real case will also catch a legitimate one, get disabled, and then enforce nothing. Narrow rules that survive beat broad rules that do not.
Interview delivery note
Say this verbatim: "Layers impose a uniform abstraction cost on use cases with completely different needs, so the simple read and the 200-line reporting query have to fit the same repository interface, and one of them ends up bypassing it. Vertical slices let each use case pick its own amount of abstraction without any of them being an exception to a rule." It states the actual defect in layering rather than the file-count complaint.
The senior-versus-staff separator is converting a pattern name into a measured defect. A senior engineer identifies a dual write. A staff engineer counts the divergence, 214 orders with no event and 31 events with no order over ninety days, and uses that to get the outbox funded, because a pattern name is an aesthetic argument and a data-integrity count is not. The same move applies to speculative generality: 147 single-implementation interfaces, of which a sample of 20 showed 17 had never been swapped.
The second signal is migrating opportunistically rather than in a big bang. Converting a feature to a slice only when someone touches it makes the restructure ride on work that was happening anyway and self-prioritises onto the code that actually changes. In one case that reached 41 of 63 features in a quarter, and the 22 untouched ones were themselves information: nobody had needed to change them.
Further reading
- Jimmy Bogard's writing and talks on vertical slice architecture, and "Repository is Dead" for the generic-repository critique.
- Brown, Malveau, McCormick and Mowbray, AntiPatterns (1998), the original catalogue.
- Sam Newman, Monolith to Microservices, for the distributed monolith and shared-database integration entries.
- Chris Richardson's microservices patterns catalogue, for the transactional outbox as the dual-write fix.
- The repository, unit of work and specification page for the full repository argument, and modular monolith vs microservices for where slice boundaries become service boundaries.