Part 2. Vertical Slice Architecture Inside a Modular Monolith

In the first post of this series, I talked about enforcing real module boundaries. Assemblies. Internals. Contracts. The boring but essential stuff that stops your modular monolith collapsing into a shared-nothing-in-name-only mess.
But once you’ve done that work, something else becomes painfully obvious.
You open a module and… it’s still chaos.
Not architectural chaos. Organised chaos. The kind that looks tidy at first glance but slows you down every time you touch it. Folders called Controllers, Services, Repositories. Logic smeared across three or four layers. You change a feature and end up hopping between files like you’re playing whack-a-mole.
This is where most modular monoliths stall.
They have boundaries, but inside those boundaries they’re still layered.
And layered architecture optimises for one thing only, explaining code to a diagram. It does not optimise for change.
The Real Unit of Change Is a Feature
Here’s the core idea behind vertical slice architecture, and it’s deceptively simple:
Code should be organised around things that change together.
Not around technical concerns. Not around frameworks. Around features.
When a product owner asks for a change, they don’t ask you to “update the service layer and adjust the repository abstraction”. They ask you to change behaviour. That behaviour should live in one place.
When I’m deep in a system late at night, after the house has finally gone quiet, I don’t want to reconstruct a feature in my head from five folders. I want to open one place and see the whole story.
Vertical slices give you that.
What Layered Architecture Looks Like in Practice
Most of us have built this:
Users/
Controllers/
UsersController.cs
Services/
UserService.cs
Repositories/
UserRepository.cs
Models/
User.cs
On paper, it looks reasonable. Responsibilities are separated. Everything has a place.
In reality, a single endpoint touches all of it.
Change the behaviour of “Create User” and you’re editing:
A controller
A service
A repository
Possibly a validator
Possibly a mapper
The logic is scattered. The mental overhead is high. And the risk of breaking something unrelated creeps up over time.
What a Vertical Slice Actually Is
A vertical slice is everything needed for one use-case, grouped together.
Not just the handler. Not just the endpoint. Everything.
Here’s the same Users module, sliced vertically:
Users/
CreateUser/
CreateUserEndpoint.cs
CreateUserHandler.cs
CreateUserCommand.cs
CreateUserValidator.cs
GetUser/
GetUserEndpoint.cs
GetUserHandler.cs
GetUserQuery.cs
UsersModule.cs
Each folder answers a single question:
“How does this feature work?”
You don’t jump around. You don’t search across the solution. You stay inside the slice.
Vertical Slices Inside a Module (Not Instead of Modules)
Vertical slice architecture does not replace modular architecture. It lives inside it.
The hierarchy looks like this:

Modules define ownership and boundaries.
Slices define behaviour and flow.
If you skip modules and go straight to slices, you end up with feature soup. If you skip slices and stick to layers, you end up with tightly coupled sludge.
You need both.
One Request, One Handler, One Path
A rule I follow almost religiously:
One request = one handler = one execution path
No shared “UserService” with 17 methods. No god-objects. No orchestration hidden behind abstractions.
Here’s a simple example.
The request
public sealed record CreateUserCommand(
string Email,
string DisplayName
);
The handler
internal sealed class CreateUserHandler(
IUserDbContext db,
IClock clock
)
{
public async Task<Result<Guid>> Handle(
CreateUserCommand command,
CancellationToken ct)
{
var user = new User(
Guid.NewGuid(),
command.Email,
command.DisplayName,
clock.UtcNow);
db.Users.Add(user);
await db.SaveChangesAsync(ct);
return Result.Success(user.Id);
}
}
No layers. No indirection. The behaviour is right there.
If validation fails, it fails here. If persistence changes, it changes here. If rules evolve, this is the file you open.
“But Won’t This Duplicate Code?”
Yes.
Sometimes.
And that’s fine.
Layered architecture optimises for reuse. Vertical slices optimise for clarity and change.
If two features genuinely share logic, extract it. But don’t pre-emptively abstract “just in case”. That’s how shared services become dumping grounds.
I’ve learned to trust duplication far more than premature reuse. Duplication is obvious. Coupling is subtle.
Validation Lives With the Slice
Another mistake I see a lot is “centralised validation”.
It sounds sensible until you realise validation rules are part of the behaviour, not infrastructure.
internal sealed class CreateUserValidator
: AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(100);
}
}
This validator belongs with CreateUser. Not in a shared folder. Not in a generic pipeline that hides rules from the feature they apply to.
When a rule changes, you want to see it next to the behaviour it affects.
Endpoints Are Just Adapters
In a vertical slice, endpoints become very boring. That’s a good thing.
internal sealed class CreateUserEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("/users", async (
CreateUserCommand command,
CreateUserHandler handler,
CancellationToken stopToken) =>
{
var result = await handler.Handle(command, stopToken);
return result.Match(
dto => Results.Created($"/users/{id}", dto),
r => Results.BadRequest(r));
});
}
}
No logic. No branching. No decisions. The endpoint adapts HTTP to your slice and gets out of the way.
Why This is important as the System Grows
This is the part people consistently underestimate. Vertical slices do not feel revolutionary when the system is small and everyone still remembers how everything fits together. Early on, almost any structure feels workable. The payoff only becomes obvious months later, when the codebase has grown, the team has changed, and the original mental model has faded. As the system evolves, clean boundaries let you delete features without collateral damage. You can remove an entire slice and be confident that you have not silently broken unrelated behaviour elsewhere. Refactoring becomes safer because changes stay contained. You are working inside a known boundary instead of tiptoeing through a shared codebase, hoping nothing unexpected snaps. That same structure also keeps future options open. If a slice starts to demand independent scaling, ownership, or deployment, it is already shaped in a way that can be extracted into a service. You are not forced into that move, but you are not blocked by your architecture either. The decision becomes a trade-off, not a rescue mission.
On a more human level, this structure matters when you are under pressure, switching context between work and home life, or simply mentally tired. Clear slices reduce friction. You do not have to re-learn the entire system every time you touch it. You can step into one area, make a change, and step back out again with confidence.
That is the real long-term value. Future-you gets fewer surprises, fewer late-night debugging sessions, and a system that continues to make sense even after the original decisions are no longer fresh in your head.
Vertical Slices and Testing
Testing looks very different once you commit to vertical slices, because the slice itself becomes the unit under test. You stop testing vague layers like “the service layer” or “the application layer” and start testing concrete behaviours. Instead of asking whether the system works in general, you ask whether CreateUser does exactly what it claims to do. Handler tests focus on behaviour. Given a valid request, does the handler produce the correct outcome and side effects? Validator tests are narrower and stricter. They exist to prove that the rules are enforced consistently, regardless of where the request comes from. Endpoint tests sit one level higher again, validating that routing, binding, authorisation, and wiring are correct.
The important part is what you do not need. There are no sprawling test fixtures that try to boot half the system. There are no magic mocks that exist only to satisfy an overly broad abstraction. Each test has a clear purpose and a tight scope, mirroring the structure of the slice itself. Because each slice stands on its own, the test suite stays readable as it grows. When a test fails, you know exactly where to look and why it matters. The structure of the code and the structure of the tests reinforce each other, which is exactly what you want in a system that is expected to evolve over time.
A Quiet Benefit: Easier Onboarding
There is a quieter benefit to vertical slices that I did not fully appreciate at first, and that is onboarding. When someone new joins the team, the system does a surprising amount of the explaining for you. Instead of walking them through layers, conventions, and unwritten rules, you can point to a single feature and say, “everything for that behaviour is here”.
That simple framing removes the need for an architectural lecture on day one. A new developer does not have to build a mental map of the entire codebase before they can be productive. They can open one slice, see the endpoint, the handler, the validation, and the tests, and understand how the system works by following a real, concrete example. Thats more important than most people like to admit. Onboarding is where hidden complexity shows up fast, and it is also where architectural decisions either pay dividends or create friction. Vertical slices reduce that friction by making the structure of the system obvious, discoverable, and grounded in real behaviour rather than abstract rules.
Bringing It Back to Reality
I don’t build systems like this because it’s fashionable. I build them this way because I want to stay productive over the long haul. I have so many side projects going at any given time that it needs to be easy for me to switch contexts quickly and be able to get moving without learning the layout again after a break working on it.
Modules give you safety.
Vertical slices give you speed.
You need both.
Up Next in the Series
Now that we’ve:
Enforced boundaries between modules
Structured behaviour inside modules
The next hard problem shows up immediately:
How do you isolate data per module without breaking consistency?
That’s where multiple DbContexts, transactions, and reality collide.
That’s what we’ll tackle next.





