Vertical Slice Architecture (VSA) is one of the most popular ways to structure .NET projects today.
It structures an application by features instead of technical layers.
In a traditional layered project, you have a Controllers folder, a Services folder, and a Repositories folder (and probably way more).
To change one feature, you jump between three or four folders (or even projects).
In VSA, every feature is a single slice.
The endpoint, business logic, validation, and data access for that feature live next to each other.
This brings several benefits:
- High cohesion within a feature
- Loose coupling between features
- Faster navigation in the codebase
- Independent changes to one feature do not take effect on the others
I have built many systems with VSA and shipped them to production, and over the years, my layout has settled into a single shape that I now reuse in every new project.
In this post, we will explore:
- How I Structure my Projects with Vertical Slice Architecture
- The Shared folder within a module
- Auto-registration of endpoints, and handlers
- Cross-slice side effects via events
- Inter-module communication via PublicApi
Let's dive in.
👉 Read original article on my newsletter: https://antondevtips.com/blog/how-to-structure-production-apps-with-vertical-slice-architecture-in-dotnet-in-2026
How I Structure my Projects with Vertical Slice Architecture
There are many ways to structure a slice.
In all of them, each vertical slice is placed in its own folder named after the use case.
Here are the most popular ones:
1. Each class for a feature is in a separate file name
2. Single file per slice with nested classes
public static class CreateShipment
{
public sealed record Request(...);
public sealed record Response(...);
public class Validator : AbstractValidator<Request> { }
public static void MapEndpoint(WebApplication app)
{
app.MapPost("/api/shipments", Handle);
}
private static async Task<IResult> Handle() { }
}
3. Separate file with each concern
This code structure combines the advantages of the first two options:
- You put the request and response modules together in the endpoint file
- You don't have too many files, as concerns are separated
- You don't have a static file that nests other classes
This is the exact approach I use in production.
Each slice is a folder named after the use case.
Inside the folder, I keep four files, each focused on one concern.
For the "Create Shipment" use case, the layout looks like this:
Features/
└── CreateShipment/
├── CreateShipment.Endpoint.cs
├── CreateShipment.Handler.cs
├── CreateShipment.Mapping.cs
└── CreateShipment.Validators.cs
When a slice raises events that other modules react to, I add an Events subfolder:
Features/
└── CreateShipment/
├── CreateShipment.Endpoint.cs
├── CreateShipment.Handler.cs
├── CreateShipment.Mapping.cs
├── CreateShipment.Validators.cs
└── Events/
├── ShipmentCreatedEvent.cs
├── UpdateStockEventHandler.cs
└── CreateCarrierEventHandler.cs
The naming convention uses a dot suffix: {Slice}.Endpoint.cs, {Slice}.Handler.cs, and so on.
This keeps files easy to find in the IDE search and easy to scan in the file tree.
I also extract cross-cutting concerns, such as validators and mappers, into separate files, so I don't clutter the main file with too many classes.
The Endpoint File
The endpoint file contains the request, response records and the Minimal API endpoint class.
The endpoint is responsible for:
- Parsing the HTTP request
- Running validation
- Calling the handler
- Translating the handler result into an HTTP response
Here is CreateShipment.Endpoint.cs:
public sealed record CreateShipmentRequest(
string OrderId,
Address Address,
string Carrier,
string ReceiverEmail,
List<ShipmentItemRequest> Items);
public class CreateShipmentApiEndpoint : IApiEndpoint
{
public void MapEndpoint(WebApplication app)
{
app.MapPost(RouteConsts.BaseRoute, Handle);
}
private static async Task<IResult> Handle(
[FromBody] CreateShipmentRequest request,
IValidator<CreateShipmentRequest> validator,
ICreateShipmentHandler handler,
CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var response = await handler.HandleAsync(request, cancellationToken);
if (response.IsError)
{
return response.Errors.ToProblem();
}
return Results.Ok(response.Value);
}
}
The endpoint class implements a small marker interface called IApiEndpoint:
public interface IApiEndpoint
{
void MapEndpoint(WebApplication app);
}
This interface lets me discover and register all endpoints in a single line at startup (more on that later).
response.Errors.ToProblem() is an extension method that maps error types (Conflict, NotFound, Validation, and so on) to the appropriate HTTP status codes.
Validation runs explicitly in the endpoint, before the handler is called.
I do not use a MediatR pipeline behavior, like in many implementations.
The validation flow stays simple and easy to follow.
The Handler File
I implement the business logic inside the Handler class.
I use a manual handler pattern, which is a simple class without an interface.
I do not use MediatR. Instead, I have a small marker interface called IHandler:
public interface IHandler;
Every handler class in the project extends IHandler.
This lets me auto-register handlers with assembly scanning.
Here is CreateShipment.Handler.cs:
internal sealed class CreateShipmentHandler(
ShipmentsDbContext context,
IStockModuleApi stockApi,
IEventPublisher eventPublisher,
ILogger<CreateShipmentHandler> logger)
: IHandler
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentRequest request,
CancellationToken cancellationToken)
{
var shipmentExists = await context.Shipments
.AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);
if (shipmentExists)
{
logger.LogInformation("Shipment for order '{OrderId}' already exists", request.OrderId);
return ShipmentErrors.AlreadyExists(request.OrderId);
}
var stockRequest = CreateCheckStockRequest(request);
var stockResponse = await stockApi.CheckStockAsync(stockRequest, cancellationToken);
if (!stockResponse.IsSuccess)
{
logger.LogInformation("Stock check failed: {@Errors}", stockResponse.Errors);
return stockResponse.Errors;
}
var shipmentNumber = new Faker().Commerce.Ean8();
var shipment = request.MapToShipment(shipmentNumber);
await context.Shipments.AddAsync(shipment, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created shipment: {@Shipment}", shipment);
var shipmentCreatedEvent = new ShipmentCreatedEvent(shipment);
await eventPublisher.PublishAsync(shipmentCreatedEvent, cancellationToken);
return shipment.MapToResponse();
}
private static CheckStockRequest CreateCheckStockRequest(CreateShipmentRequest request)
{
return new CheckStockRequest(
request.Items
.Select(x => new ProductStock(x.Product, x.Quantity))
.ToList()
);
}
}
Notice how simple the structure is.
No extra interfaces, no commands, no command handlers, no mediator, no magic navigation to implementation, just a direct call to an exact class.
Instead of throwing exceptions for expected errors, the handler uses a Result Pattern and returns Result<ShipmentResponse>.
Errors for the module live in a single static class, so they stay consistent and easy to find:
internal static class ShipmentErrors
{
private const string ErrorPrefix = "Shipments";
internal static Error NotFound(string shipmentNumber) =>
Error.NotFound($"{ErrorPrefix}.{nameof(NotFound)}",
$"Shipment with number '{shipmentNumber}' not found");
internal static Error AlreadyExists(string orderId) =>
Error.Conflict($"{ErrorPrefix}.{nameof(AlreadyExists)}",
$"Shipment for order '{orderId}' already exists");
}
Cross-module calls go through public APIs.
The handler does not reference any internals of the Stocks module.
It only uses IStockModuleApi, which is exposed by a separate Modules.Stocks.PublicApi project.
Events fire after the save.
Once the shipment is persisted, the handler publishes a ShipmentCreatedEvent.
Other slices and other modules can react to this event without the handler knowing about them.
The Mapping and Validation Concerns
I do all object mapping manually, in static extension methods (I don't use mapping libraries like AutoMapper, Mapster or Mapperly).
internal static class CreateShipmentMappingExtensions
{
public static Shipment MapToShipment(this CreateShipmentRequest request)
{
// Mapping code omitted for brevity
}
public static ShipmentResponse MapToResponse(this Shipment shipment)
{
// Mapping code omitted for brevity
}
}
Manual mapping has two big advantages:
- It is explicit: you can navigate to it directly and know exactly what happens
- It has no reflection overhead
If a mapping is reused across multiple slices, I move it into the Shared/ folder of the module instead of duplicating it.
For validation, I use FluentValidation.
I keep all validators for a slice in one file:
public class CreateShipmentRequestValidator : AbstractValidator<CreateShipmentRequest>
{
public CreateShipmentRequestValidator()
{
RuleFor(shipment => shipment.OrderId).NotEmpty();
RuleFor(shipment => shipment.Carrier).NotEmpty();
RuleFor(shipment => shipment.ReceiverEmail).NotEmpty();
RuleFor(shipment => shipment.Items).NotEmpty();
RuleFor(shipment => shipment.Address)
.Cascade(CascadeMode.Stop)
.NotNull()
.WithMessage("Address must not be null")
.SetValidator(new AddressValidator());
}
}
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(address => address.Street).NotEmpty();
RuleFor(address => address.City).NotEmpty();
RuleFor(address => address.Zip).NotEmpty();
}
}
The Shared Folder Within a Module
Some things are needed by more than one slice in the same module (of a Modular Monolith): route constants, errors and response shapes for related slices.
To avoid duplication, I put them in a Shared/ folder inside the module's Features project.
Features/
├── CreateShipment/
├── DispatchShipment/
├── GetShipmentByNumber/
└── Shared/
├── Errors/
│ └── ShipmentErrors.cs
├── Requests/
│ └── ShipmentItemRequest.cs
├── Responses/
│ ├── ShipmentResponse.cs
│ └── ShipmentItemResponse.cs
└── Routes/
└── RouteConsts.cs
For all the API endpoint names, I used a static class called RouteConsts:
internal static class RouteConsts
{
internal const string BaseRoute = "/api/shipments";
internal const string GetByNumber = $"{BaseRoute}/{{shipmentNumber}}";
internal const string CancelShipment = $"{BaseRoute}/cancel/{{shipmentNumber}}";
internal const string DispatchShipment = $"{BaseRoute}/dispatch/{{shipmentNumber}}";
// ... and so on
}
I wrote a detailed guide on how to share code and avoid code duplication in Vertical Slices. Read it here.
👉 Read original article on my newsletter: https://antondevtips.com/blog/how-to-structure-production-apps-with-vertical-slice-architecture-in-dotnet-in-2026

Top comments (0)