When to Use Vertical Slices: Combining Them with Clean Architecture and Modular Monoliths
Vertical slices work well for APIs that gain features often and whose use cases vary in complexity; they aren't enough on their own for teams without refactoring discipline or for rich, shared domains, and they work best together with a Clean domain core and modular monolith boundaries. We split Bookstore into modules and protect the boundary with internal and ArchUnitNET, evaluate REPR with FastEndpoints, and lay out a plan for moving a layered project to slices gradually.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Today Bookstore is a layered monolith run by a team of three developers and deployed once a week. According to the growth targets we set in the Introduction to Architecture series, within 12–18 months there will be four separate teams: Catalog and search, Ordering and payment, Fulfillment, Customer Experience. Having read the first five posts of this series, the team lead comes with a proposal: "Let's convert the whole application to Vertical Slice Architecture and drop the Clean Architecture structure in Ordering too."
Guess: which parts of Bookstore fit slices very well, and where are slices alone not enough? When four teams start working in the same codebase, what do slices solve and what don't they?
When It Fits
Putting together what we saw in the series' five posts, the situations where VSA (Vertical Slice Architecture) helps most become clear:
- APIs (Application Programming Interface) that get new features often: where several developers write different features at the same time, as in the Ada and Alan scenario of post 01, fewer shared files directly lowers the cost of merging.
- Mostly CRUD (create, read, update, delete) but growing applications: read slices fetch exactly what the screen needs with projections (02), and write slices without business rules can stay transaction scripts.
- Codebases where use cases differ widely in complexity: one slice can run a plain query, another a rich domain model; no single structure is imposed on the whole application.
- Teams that want to test through behaviour: an integration test per slice, from HTTP (Hypertext Transfer Protocol) to the database (05), provides a safety net that doesn't change during refactoring.
In Bookstore, Catalog administration matches this description exactly: add a book, update a price, search, list. Few rules, many screens, frequent change.
When It Doesn't Fit
In three situations VSA either does harm or gains little. First, the condition Bogard set himself: if the team doesn't recognize code smells and doesn't refactor, slices turn into a pile of copy-paste; the 7-copy order in post 04 came from a rule forgotten in the second slice. Second, regions where the domain is very rich and largely shared: if almost every request goes through the same rules of the same aggregate, slices remain a thin shell, the real design work happens in the domain model, and there the tools of Domain-Driven Design and Clean Architecture are what matter. Third, applications with a handful of endpoints or a short life: it hardly matters which layout you pick, so pick the simplest.
There's also a problem VSA doesn't solve: boundaries between teams. Slices gather a feature's code together, but they don't answer "may the Catalog team touch Ordering's tables?". For Bookstore's four-team future, that question has to be answered one level above the slice, at the module boundary. That's why the answer to both halves of the team lead's proposal is "partly": slices yes, but without removing Ordering's core and with module boundaries drawn.
Hybrid 1: A Clean Core and Slices
The Clean, Onion, and Hexagonal Architecture series post "When Is Clean Architecture Too Much? Template Cargo Cults, Interface Explosion, and a Pragmatic Path" ended by saying the decision can be made per region rather than per application. Combining VSA with Clean Architecture is exactly that: the Domain core and the Dependency Rule stay, while the use cases on the Application side become slices instead of service classes. In Ordering, Order and OrderItem live in the Domain namespace with their invariants from post 04; the PlaceOrder, CancelOrder, and Checkout slices from post 04 use them, and in the module example below PlaceOrder works with the same Order. The Dependency Rule is protected by an architecture test rather than the compiler; the rule is the first method of the test class below.
In this hybrid the most expensive parts of Clean Architecture go away: an interface per use case, a separate Application project, a request, command, and DTO (Data Transfer Object) trio per use case. What stays is what's really valuable: a domain core that can be tested without a database or HTTP and doesn't depend on frameworks.
Hybrid 2: Slices Inside a Modular Monolith
In the Introduction to Architecture series post "Understanding the Monolith: Single-Process, Modular, and Distributed Monoliths" we defined a modular monolith as a structure of clearly bounded modules inside one process and one deployment. Slices sit at the lowest level of that structure:

Each module is a separate project: its own DbContext, its own slices, and a small contract it opens to the outside. Catalog's contract is a single interface:
namespace Bookstore.Catalog.Contracts;
// The only door the Catalog module opens to the outside
public interface IBookCatalog
{
Task<IReadOnlyDictionary<int, decimal>> GetPricesAsync(IReadOnlyCollection<int> bookIds, CancellationToken ct);
}
The rest of the module is internal; only the contract and the registration methods are public:
internal sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
}
internal sealed class BookCatalog(CatalogDbContext db) : IBookCatalog
{
public async Task<IReadOnlyDictionary<int, decimal>> GetPricesAsync(IReadOnlyCollection<int> bookIds, CancellationToken ct) =>
await db.Books.Where(b => bookIds.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
}
public static class CatalogModule
{
public static IServiceCollection AddCatalogModule(this IServiceCollection services, string connectionString)
{
services.AddDbContext<CatalogDbContext>(o => o.UseSqlite(connectionString));
services.AddScoped<IBookCatalog, BookCatalog>();
return services.AddSlicesFrom(typeof(CatalogModule).Assembly);
}
public static void MapCatalogModule(this IEndpointRouteBuilder app) =>
app.MapSlicesFrom(typeof(CatalogModule).Assembly, "/api/catalog");
}
Ordering's PlaceOrder slice gets prices from Catalog's contract, not from its table:
// The slice reaches Catalog's data through Catalog's contract, not through its own DbContext
internal sealed class PlaceOrderHandler(OrderingDbContext db, IBookCatalog catalog)
{
public async Task<PlaceOrderResponse?> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
IReadOnlyDictionary<int, decimal> prices = await catalog.GetPricesAsync(ids, ct);
if (prices.Count != ids.Length)
return null;
Order order = new(request.CustomerId);
foreach (PlaceOrderLine line in request.Lines)
order.AddItem(line.BookId, line.Quantity, prices[line.BookId]);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Total);
}
}
The host only puts the modules together:
using Bookstore.Catalog;
using Bookstore.Ordering;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddCatalogModule("Data Source=catalog.db");
builder.Services.AddOrderingModule("Data Source=ordering.db");
WebApplication app = builder.Build();
app.Services.SeedCatalog();
app.Services.CreateOrderingDatabase();
app.MapCatalogModule();
app.MapOrderingModule();
app.Run();
GET /api/catalog/books/1
HTTP 200 {"id":1,"title":"Dune","price":12.5}
POST /api/ordering/orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP 201 {"orderId":1,"total":33.0}
The compiler is the first guard of the module boundary. When a developer in Ordering tries to read Catalog's table "as a shortcut" for a report:
using Bookstore.Catalog;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Features.BookSales;
// A shortcut attempt: reading Catalog's table directly
internal sealed class BookSalesHandler(CatalogDbContext catalogDb)
{
public Task<int> CountBooksAsync(CancellationToken ct) => catalogDb.Books.CountAsync(ct);
}
Bookstore.Ordering/Features/BookSales/BookSales.cs(7,40): error CS0122: 'CatalogDbContext' is inaccessible due to its protection level
But for types that must stay public, like CatalogModule, internal isn't enough. The second guard, together with the domain rule from Hybrid 1, is a set of architecture tests:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnitV3;
using Bookstore.Catalog;
using Bookstore.Ordering;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition;
namespace Bookstore.ArchitectureTests;
public sealed class ModuleRules
{
private static readonly Architecture Architecture =
new ArchLoader().LoadAssemblies(typeof(CatalogModule).Assembly, typeof(OrderingModule).Assembly).Build();
// The Clean core: Ordering's domain knows neither frameworks nor slices
[Fact]
public void Ordering_domain_depends_on_nothing_outside_itself() =>
Types().That().ResideInNamespaceOrBelow("Bookstore.Ordering.Domain").Should()
.NotDependOnAny(Types(true).That()
.ResideInNamespaceOrBelow("Bookstore.Ordering.Features")
.Or().ResideInNamespaceOrBelow("Bookstore.Catalog")
.Or().ResideInNamespaceOrBelow("Microsoft.EntityFrameworkCore")
.Or().ResideInNamespaceOrBelow("Microsoft.AspNetCore"))
.Check(Architecture);
// One door between modules: Ordering sees only Catalog's Contracts
[Fact]
public void Ordering_uses_only_catalog_contracts() =>
Types().That().ResideInNamespaceOrBelow("Bookstore.Ordering").Should()
.NotDependOnAny(Types().That().ResideInNamespaceOrBelow("Bookstore.Catalog")
.And().DoNotResideInNamespaceOrBelow("Bookstore.Catalog.Contracts"))
.Check(Architecture);
[Fact]
public void Slices_within_a_module_do_not_depend_on_each_other() =>
Slices().Matching("Bookstore.Ordering.Features.(**)").Should().NotDependOnEachOther().Check(Architecture);
}
When I added an admin slice to Ordering that calls Catalog's SeedCatalog method, the build passed and the test broke:
passed Bookstore.ArchitectureTests.ModuleRules.Ordering_domain_depends_on_nothing_outside_itself (81ms)
passed Bookstore.ArchitectureTests.ModuleRules.Slices_within_a_module_do_not_depend_on_each_other (1ms)
failed Bookstore.ArchitectureTests.ModuleRules.Ordering_uses_only_catalog_contracts (4ms)
ArchUnitNET.xUnitV3.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Ordering" or below should not depend on any Types that reside in namespace with full name "Bookstore.Catalog" or below and do not reside in namespace with full name "Booksto..." failed:
Bookstore.Ordering.Features.Admin.ResetCatalogHandler does depend on "Bookstore.Catalog.CatalogModule"
Each of the three levels has its own question. The module answers "which team owns this data and these rules?"; the slice answers "where is this feature's code?"; the domain core serves the need "this rule must never break". Events between modules, a schema per module, and the decision to extract a module into a separate service are topics for the upcoming Modular Monolith series.
REPR and FastEndpoints
Thinking of a slice at the endpoint level has a name too: REPR (Request-Endpoint-Response). The pattern, named by Steve "Ardalis" Smith, treats each endpoint as a request type, an endpoint class, and a response type; no controller. In the README of his own ApiEndpoints library, Smith now recommends FastEndpoints for this approach. Bookstore's order slice with FastEndpoints 8.3.0 (MIT):
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Fe.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);
public sealed class PlaceOrderValidator : Validator<PlaceOrderRequest>
{
public PlaceOrderValidator()
{
RuleFor(r => r.CustomerId).GreaterThan(0);
RuleFor(r => r.Lines).NotEmpty();
RuleForEach(r => r.Lines).ChildRules(line => line.RuleFor(l => l.Quantity).InclusiveBetween(1, 5));
}
}
// REPR: Request, Endpoint, Response; the whole slice is one class
public sealed class PlaceOrderEndpoint(BookstoreDbContext db) : Endpoint<PlaceOrderRequest, PlaceOrderResponse>
{
public override void Configure()
{
Post("/api/orders");
AllowAnonymous();
}
public override async Task HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
if (prices.Count != ids.Length)
{
AddError(r => r.Lines, "Unknown book");
await Send.ErrorsAsync(cancellation: ct);
return;
}
Order order = new() { CustomerId = request.CustomerId, Total = request.Lines.Sum(l => prices[l.BookId] * l.Quantity) };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await Send.OkAsync(new PlaceOrderResponse(order.Id, order.Total), ct);
}
}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP 200 {"orderId":1,"total":33.0}
POST /api/orders {"customerId":0,"lines":[{"bookId":1,"quantity":9}]}
HTTP 400 {"statusCode":400,"message":"One or more errors occurred!","errors":{"customerId":["'customer Id' must be greater than '0'."],"lines[0].quantity":["'quantity' must be between 1 and 5. You entered 9."]}}
POST /api/orders {"customerId":7,"lines":[{"bookId":42,"quantity":1}]}
HTTP 400 {"statusCode":400,"message":"One or more errors occurred!","errors":{"lines":["Unknown book"]}}
Registration in Program.cs is two lines: AddFastEndpoints() and UseFastEndpoints(); the library finds endpoints and validators itself, a ready-made version of the IEndpoint scan from post 02. What it gives you: one class per slice, built-in FluentValidation integration, performance close to Minimal APIs (the project's own claim). There are things to know too. The endpoint derives from a base class and brings a library-specific vocabulary such as Send.OkAsync and AddError; if you want to leave, you touch every slice. The default error body isn't RFC (Request for Comments) 9457 ProblemDetails but its own ErrorResponse shape; according to the package's documentation it can be switched with UseFastEndpoints(c => c.Errors.UseProblemDetails()). And a small surprise: on my first try the validation messages came back in Turkish, because FluentValidation uses the machine's culture (tr-TR); the output above is the run repeated with an English culture. The ardalis/CleanArchitecture template we examined in the Clean series uses FastEndpoints too; if the team likes REPR it's a reasonable default, but Minimal APIs plus the 40 lines of infrastructure from post 02 do the same job without a library dependency.
Moving a Layered Project Gradually
Most teams come to VSA not from scratch but from a working layered project. Rather than trying to move everything in one sprint, it's safer to grow the new structure next to the old one. First measure where to start; the most-changed files in the git history of the layered Bookstore from post 01:
$ git log --all --format= --name-only | grep . | sort | uniq -c | sort -rn | head -6
3 Services/OrderService.cs
3 Services/IOrderService.cs
3 Repositories/OrderRepository.cs
3 Repositories/IOrderRepository.cs
3 Endpoints/OrderEndpoints.cs
1 Program.cs
In a real project this list is long and scattered; the files at the top are where the move starts. The plan:
- Set up the infrastructure: the
Featuresfolder, theIEndpointscan, and the Testcontainers fixture for slice tests. Existing endpoints keep working as they are. - From now on, every new feature is written as a slice; no new methods are added to shared services.
- Move the methods of the most-changed service into slices one by one; before each move write a slice test for that behaviour, then delete the method from the service.
- During the move, extract rules duplicated across two handlers into the entity (04).
- Protect with an architecture test that slices don't depend on each other and that no new dependencies are added to the old services; freeze existing violations with the
FreezingArchRulefrom the last post of the Clean series. - When a service's last method has moved, delete the service, its interface, and its repository, and the emptied layer folders too.
The beauty of this approach is that the application never stops: at the end of every sprint it runs, the old and new structures sit side by side, and the folder tells you which code is in which structure. Old code that stays CRUD and that nobody touches doesn't have to move; the architecture of code that doesn't change costs nobody anything.
Common Pitfalls
Changing the architecture in one go. A "big bang" move means a branch that lasts months, two half-finished structures, and a frozen feature flow. A gradual move looks slower, but every step can be undone.
Imposing VSA without distinguishing regions. Catalog's CRUD slices and Ordering's rule-heavy domain don't deserve the same treatment. Decide per region and write the decision down: an ADR (Architecture Decision Record), which we'll see in the upcoming Architecture Decisions series, is the natural home for a decision like "a domain core is mandatory in Ordering, not in Catalog".
Drawing the module boundary with folders only. Creating Modules/Catalog and Modules/Ordering folders in the same project doesn't stop a developer from using Catalog's DbContext from Ordering. You need separate projects and internal, or at least an architecture test.
Mistaking a REPR library for an architecture. FastEndpoints or a mediator is one way to write a slice; dependencies between slices, domain rules, and module boundaries are still your decisions.
Wrapping Up the Series
Over six posts we followed one idea: grouping code not by technical layer but along the axis change comes from, that is, by feature. In 01 we saw the same two features conflict in five files in a layered codebase and in one in a sliced codebase. In 02 we built a slice without a library and caught how a naming convention can take down every endpoint. In 03 we measured three mediators and saw that dispatch cost is negligible while licensing, Native AOT (ahead-of-time compilation), and setup pitfalls are decisive. In 04 we moved invariants into the domain and saw how a slice calling a slice saved an order whose payment was declined. In 05 we tested slices against a real SQL (Structured Query Language) Server database and saw the InMemory provider both pass and fail wrongly. And in this post we built how VSA works together with Clean Architecture's core and a Modular Monolith's boundaries. The sentence to remember: VSA isn't a folder structure but a decision about where coupling should be high and where low; what keeps that decision standing is refactoring discipline and the tests that protect it.
A Small Experiment
★☆☆ Produce a change heat map of the last six months of your own project with git log. How many of the five most-changed files are shared by more than one feature?
★★☆ Add an Inventory module to the modular monolith example. Make PlaceOrder use Inventory's contract for the stock check and write the same rule for Inventory in ModuleRules.
★★★ In the FastEndpoints example, switch the error body to ProblemDetails with UseProblemDetails() and compare it with the error response of the Minimal API slice from post 02. If the same client used both APIs, which differences would cause trouble?
That's the end of the Vertical Slice Architecture series. In the Software Architecture category, the road continues into two topics this post opened the door to: Domain-Driven Design, for designing Order as an aggregate and for bounded contexts, and Modular Monolith, for the details of module boundaries and communication between modules.