What Is Vertical Slice Architecture? Organizing Code by Feature, Not by Layer
Vertical Slice Architecture slices code by what each request needs end to end rather than by technical layer, so coupling drops between slices and rises inside them. With real git output we watch the same two features conflict in five files in a layered Bookstore and in one file in a sliced one, then look at a slice's anatomy and three common misreadings.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The ordering side of Bookstore is layered, just like in the Introduction to Architecture series post "Layered Architecture: The Real Responsibilities of Presentation, Business, and Data Layers": endpoints call an IOrderService, the service calls an IOrderRepository, and the repository reaches the database through Entity Framework Core (EF Core). There are two features today: placing an order and viewing its details.
Data/BookstoreDbContext.cs
Domain/Order.cs
Dtos/OrderDtos.cs
Endpoints/OrderEndpoints.cs
Mapping/OrderMapper.cs
Program.cs
Repositories/IOrderRepository.cs
Repositories/OrderRepository.cs
Services/IOrderService.cs
Services/OrderService.cs
On Monday morning two developers pick up two separate tasks. Ada will write order cancellation (CancelOrder), Alan will write listing a customer's orders (GetCustomerOrders). The two never touch each other's work: different endpoints, different queries, different business rules. Each works on a branch and merges at the end of the day.
Guess: when these two independent features are merged, how many files end up in conflict? Zero, one, more?
Two Independent Features, Five Conflicts
The changes on each branch:
$ git diff --stat main cancel-order
Endpoints/OrderEndpoints.cs | 2 ++
Repositories/IOrderRepository.cs | 1 +
Repositories/OrderRepository.cs | 2 ++
Services/IOrderService.cs | 1 +
Services/OrderService.cs | 12 ++++++++++++
5 files changed, 18 insertions(+)
$ git diff --stat main customer-orders
Endpoints/OrderEndpoints.cs | 2 ++
Repositories/IOrderRepository.cs | 1 +
Repositories/OrderRepository.cs | 3 +++
Services/IOrderService.cs | 1 +
Services/OrderService.cs | 3 +++
5 files changed, 10 insertions(+)
And the merge:
$ git checkout cancel-order
$ git merge customer-orders
Auto-merging Endpoints/OrderEndpoints.cs
CONFLICT (content): Merge conflict in Endpoints/OrderEndpoints.cs
Auto-merging Repositories/IOrderRepository.cs
CONFLICT (content): Merge conflict in Repositories/IOrderRepository.cs
Auto-merging Repositories/OrderRepository.cs
CONFLICT (content): Merge conflict in Repositories/OrderRepository.cs
Auto-merging Services/IOrderService.cs
CONFLICT (content): Merge conflict in Services/IOrderService.cs
Auto-merging Services/OrderService.cs
CONFLICT (content): Merge conflict in Services/OrderService.cs
Automatic merge failed; fix conflicts and then commit the result.
All five files conflict. Nothing in the logic actually collides; both developers appended a method to the same interface and an implementation to the same class. Resolving the conflicts takes five minutes, but the real message is elsewhere: in this codebase every feature has to touch the same shared file in every layer. IOrderService is a bottleneck where everything related to orders meets.
This post, and this series, is about one way of removing that bottleneck: Vertical Slice Architecture (VSA). Slicing code not by technical layer, but by what each request needs from end to end.
Bogard's Idea: Couple Along the Axis of Change
Jimmy Bogard named the approach in a short blog post on April 19, 2018. His starting point is that layered and concentric (Onion, Clean) architectures cut an application horizontally, by technical responsibility. Change requests, however, arrive vertically: "let customers cancel an order" needs a piece of every layer, from the endpoint down to the database. So Bogard proposes coupling along the axis the change comes from, making each request (a command or a query) a slice of its own. The post boils down to one sentence: "Minimize coupling between slices, and maximize coupling in a slice."
Both halves of that sentence matter. Reducing coupling between slices makes it more likely that changing CancelOrder won't break GetCustomerOrders. Increasing coupling inside a slice means keeping a feature's endpoint, request, workflow, and query side by side; to understand the feature you open one folder instead of walking through five. The consequence Bogard emphasizes is that new features mostly add code: instead of changing shared code and worrying about side effects, you add a new folder.
The idea did not appear out of nowhere in 2018. In the September 2016 issue of MSDN (Microsoft Developer Network) Magazine, Steve Smith proposed, for large MVC (Model-View-Controller) applications, feature folders instead of the Controllers, Views, and Models folders, in an article titled "Feature Slices for ASP.NET Core MVC". .NET itself has an example of the same thinking: Razor Pages. As Microsoft Learn describes it, a Razor Page keeps the page's markup next to a PageModel that holds handler methods such as OnGet and OnPost; the request reaches the page directly, without going through a controller. In other words, ASP.NET Core has offered "one screen = two files in one folder" out of the box since 2017. VSA carries the same thinking over to API (Application Programming Interface) endpoints and the workflow behind them.
Anatomy of a Slice
Let's write the same ordering side of Bookstore with slices. The folder layout:
Domain/Order.cs
Features/Orders/GetOrderDetails/GetOrderDetails.cs
Features/Orders/PlaceOrder/PlaceOrderEndpoint.cs
Features/Orders/PlaceOrder/PlaceOrderHandler.cs
Features/Orders/PlaceOrder/PlaceOrderRequest.cs
Infrastructure/BookstoreDbContext.cs
Infrastructure/IEndpoint.cs
Program.cs
There are no Services, Repositories, Dtos, or Mapping folders. In their place, the Features/Orders/PlaceOrder folder holds everything placing an order needs. First the request and response:
namespace Bookstore.Slices.Features.Orders.PlaceOrder;
public sealed record PlaceOrderRequest(int CustomerId, List<PlaceOrderLine> Lines);
public sealed record PlaceOrderLine(int BookId, int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Total);
Then the handler that does the work. It takes the DbContext directly; there is no repository or service interface in between:
using Bookstore.Slices.Domain;
using Bookstore.Slices.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Slices.Features.Orders.PlaceOrder;
public sealed class PlaceOrderHandler(BookstoreDbContext db)
{
public async Task<PlaceOrderResponse> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
List<int> ids = [.. request.Lines.Select(l => l.BookId)];
Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
Order order = new()
{
CustomerId = request.CustomerId,
Items = [.. request.Lines.Select(l => new OrderItem { BookId = l.BookId, Quantity = l.Quantity, UnitPrice = prices[l.BookId] })]
};
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity));
}
}
And the HTTP (Hypertext Transfer Protocol) side. The endpoint is defined by its own class; Program.cs does not know which routes exist:
using Bookstore.Slices.Infrastructure;
namespace Bookstore.Slices.Features.Orders.PlaceOrder;
public sealed class PlaceOrderEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
{
PlaceOrderResponse response = await handler.HandleAsync(request, ct);
return TypedResults.Created($"/orders/{response.OrderId}", response);
});
}
IEndpoint is a single-method interface; at startup Program.cs finds every implementation in the assembly and calls Map. We'll build it properly, and a better version of it, in the next post. Order details is a completely different slice with a completely different shape:
using Bookstore.Slices.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Slices.Features.Orders.GetOrderDetails;
public sealed record OrderDetails(int Id, string Status, decimal Total, int ItemCount);
// A read-only slice: no handler class, the query lives in the endpoint
public sealed class GetOrderDetailsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapGet("/orders/{id:int}", async (int id, BookstoreDbContext db, CancellationToken ct) =>
await db.Orders
.Where(o => o.Id == id)
.Select(o => new OrderDetails(o.Id, o.Status, o.Items.Sum(i => i.UnitPrice * i.Quantity), o.Items.Count))
.SingleOrDefaultAsync(ct) is { } details
? Results.Ok(details)
: Results.NotFound());
}
This slice has no handler and no mapper, it has its own response type, and its query uses an EF Core projection to fetch only the four values it needs. In the layered version, OrderDto was the shared return type for both placing an order and showing its details; adding a field for one changed the other too. Running it:
POST /orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP/1.1 201 Created
Location: /orders/1
{"orderId":1,"total":33.0}
GET /orders/1
HTTP/1.1 200 OK
{"id":1,"status":"Placed","total":33.0,"itemCount":2}

The Same Two Features, with Slices
Ada and Alan do the same tasks, this time in the sliced codebase. Ada opens Features/Orders/CancelOrder, Alan opens Features/Customers/GetCustomerOrders:
$ git diff --stat main cancel-order
Features/Orders/CancelOrder/CancelOrderEndpoint.cs | 10 ++++++++++
Features/Orders/CancelOrder/CancelOrderHandler.cs | 21 +++++++++++++++++++++
Program.cs | 2 ++
3 files changed, 33 insertions(+)
$ git diff --stat main customer-orders
.../GetCustomerOrders/GetCustomerOrdersEndpoint.cs | 10 ++++++++++
.../GetCustomerOrders/GetCustomerOrdersHandler.cs | 15 +++++++++++++++
Program.cs | 2 ++
3 files changed, 27 insertions(+)
More lines changed, because each slice writes its own query and its own response. But the number of shared files touched dropped from five to one. The merge:
$ git merge customer-orders
Auto-merging Program.cs
CONFLICT (content): Merge conflict in Program.cs
Automatic merge failed; fix conflicts and then commit the result.
One conflict is left, and its cause is instructive:
builder.Services.AddScoped<PlaceOrderHandler>();
++<<<<<<< HEAD
+builder.Services.AddScoped<CancelOrderHandler>();
++=======
+ builder.Services.AddScoped<GetCustomerOrdersHandler>();
++>>>>>>> customer-orders
Because each slice registers its handler in Program.cs by hand, that file has become a new shared point. Making slices independent doesn't end with separating folders; every shared file each slice must touch is the address of the next conflict. In post 02 we remove these lines with an assembly scan, and then the two features merge without a sound.
How much shared code a new feature touches is the easiest VSA benefit to measure. To measure it in your own project, look at the git diff --stat output of your last few feature branches and count how many files were changed by more than one feature.
Each Slice Chooses Its Own Approach
The second idea in Bogard's post gets talked about less, but it matters at least as much: each slice decides how to fulfil its request. In the vocabulary of Martin Fowler's PoEAA (Patterns of Enterprise Application Architecture, 2002), a slice can start as a transaction script: a single procedure that handles the request from start to finish. GetOrderDetails is exactly that, or even less: a query. PlaceOrderHandler is a transaction script today too; it loads prices, builds the order, and saves it.
Bogard's suggestion is to start every slice simple and refactor when code smells appear, instead of picking one pattern for the whole application. If the "only orders in Placed status can be cancelled" rule in CancelOrder is one day needed in a ChangeShippingAddress slice too, the rule moves from the handlers into the Order entity, a step toward PoEAA's domain model pattern. In the same application one slice can run a plain SQL (Structured Query Language) query while another works with a rich domain model. In a layered architecture this decision is usually made application-wide: either everything goes through services and repositories, or nothing does.
The price of this freedom is spelled out in Bogard's post as well: the approach assumes the team can recognize code smells and refactor. Without that skill, Bogard says, VSA is probably not for you. What to share between slices and where domain logic goes is the subject of post 04.
Three Misreadings
"VSA = MediatR." Because most VSA examples in the .NET world are written with MediatR, people assume the two are the same thing. Bogard is the author of MediatR, but MediatR isn't mentioned in the 2018 post, and none of the slices above use a mediator. A mediator is one way to implement a slice; what it gains and what it costs is in post 03.
"VSA = permission to duplicate code." Slices being independent is no licence to copy the same business rule into ten slices. What should be independent is requests and responses, queries, and workflows; business rules move into the domain the moment they repeat. The problem is that in teams that skip this refactoring, VSA quickly turns into a pile of copy-paste.
"VSA is a rival to Clean Architecture." The two decide along different axes. Clean Architecture decides the direction of dependencies: source code dependencies point inward, toward the domain. VSA decides the axis along which code is grouped: by feature instead of technical layer. The last post of the Clean, Onion, and Hexagonal Architecture series, "When Is Clean Architecture Too Much? Template Cargo Cults, Interface Explosion, and a Pragmatic Path", ended with exactly this combination: keep the Domain core and the Dependency Rule, and split the Application side into slices. We'll build that hybrid in detail in post 06.
Common Pitfalls
Renaming folders and thinking you changed the architecture. Creating a Features/Orders folder and moving the old OrderService, IOrderRepository, and OrderDto into it gives you a feature folder, not a slice. The test is simple: if adding a new feature still means adding a method to a shared service class, you are in a layered architecture no matter where the files live.
Cutting slices by entity. Thinking in terms of an "Orders slice" or a "Books slice" turns a slice into the folder of a CRUD (create, read, update, delete) controller. The unit of a slice is not an entity but one thing the user asks for: PlaceOrder, CancelOrder, GetCustomerOrders. One entity can have ten different slices.
Missing hidden shared points. The Program.cs conflict above was a small example. A Constants.cs, a single OrderResponse used by every slice, or a switch expression each slice adds a line to can play the same role. Slices are only as independent as the shared files they are forced to touch.
Writing every slice from scratch in a different style. "Each slice chooses its own approach" is not permission to do the same job differently in every slice. Shared concerns such as DbContext access, error responses, and validation should have one way of being done; where slices are free is in the complexity of the workflow. We'll set concrete rules for this in the next post.
When Not to Use It
If the team is unfamiliar with refactoring and code smells, VSA's freedom soon turns into inconsistency; that skill has to come first. In an application whose domain is very rich and where almost every request touches the same complex set of rules, slices remain a thin shell and the real work happens in the domain model; VSA does no harm, but the gain is small too. In an application with a handful of endpoints, it hardly matters which layout you pick. The detailed decision guide is in post 06.
A Small Experiment
★☆☆ Open the pull requests for the last five features in your own project. How many files changed in each, and how many of those were shared files that other features also changed? Write down the three most-touched files.
★★☆ Add a ChangeShippingAddress slice to the sliced Bookstore in this post. While writing the "only orders in Placed status can be changed" rule, did you have to copy the rule from CancelOrder? Where would you move that duplication?
★★★ Repeat the Ada and Alan scenario on your own machine with two branches. Merge first in the layered version, then in the sliced version, and compare the number of conflicts. Then find a way to remove the handler registrations from Program.cs and watch the sliced merge go through silently.
In the next post we turn this skeleton into a real slice: without MediatR, with Minimal API, a handler class, and automatic endpoint registration. The assembly scan for IEndpoint, .NET 10's built-in validation, TypedResults, and why burying the handler inside the endpoint lambda is a bad idea are all there.