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.