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.
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
1public static class CreateShipment
2{
3 public sealed record Request(...);
4
5 public sealed record Response(...);
6
7 public class Validator : AbstractValidator<Request> { }
8
9 public static void MapEndpoint(WebApplication app)
10 {
11 app.MapPost("/api/shipments", Handle);
12 }
13
14 private static async Task<IResult> Handle() { }
15}
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:
1Features/
2βββ CreateShipment/
3 βββ CreateShipment.Endpoint.cs
4 βββ CreateShipment.Handler.cs
5 βββ CreateShipment.Mapping.cs
6 βββ CreateShipment.Validators.cs
When a slice raises events that other modules react to, I add an Events subfolder:
1Features/
2βββ CreateShipment/
3 βββ CreateShipment.Endpoint.cs
4 βββ CreateShipment.Handler.cs
5 βββ CreateShipment.Mapping.cs
6 βββ CreateShipment.Validators.cs
7 βββ Events/
8 βββ ShipmentCreatedEvent.cs
9 βββ UpdateStockEventHandler.cs
10 βββ 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:
1public sealed record CreateShipmentRequest(
2 string OrderId,
3 Address Address,
4 string Carrier,
5 string ReceiverEmail,
6 List<ShipmentItemRequest> Items);
7
8public class CreateShipmentApiEndpoint : IApiEndpoint
9{
10 public void MapEndpoint(WebApplication app)
11 {
12 app.MapPost(RouteConsts.BaseRoute, Handle);
13 }
14
15 private static async Task<IResult> Handle(
16 [FromBody] CreateShipmentRequest request,
17 IValidator<CreateShipmentRequest> validator,
18 ICreateShipmentHandler handler,
19 CancellationToken cancellationToken)
20 {
21 var validationResult = await validator.ValidateAsync(request, cancellationToken);
22 if (!validationResult.IsValid)
23 {
24 return Results.ValidationProblem(validationResult.ToDictionary());
25 }
26
27 var response = await handler.HandleAsync(request, cancellationToken);
28 if (response.IsError)
29 {
30 return response.Errors.ToProblem();
31 }
32
33 return Results.Ok(response.Value);
34 }
35}
The endpoint class implements a small marker interface called IApiEndpoint:
1public interface IApiEndpoint
2{
3 void MapEndpoint(WebApplication app);
4}
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:
1public interface IHandler;
Every handler class in the project extends IHandler.
This lets me auto-register handlers with assembly scanning.
Here is CreateShipment.Handler.cs:
1
2internal sealed class CreateShipmentHandler(
3 ShipmentsDbContext context,
4 IStockModuleApi stockApi,
5 IEventPublisher eventPublisher,
6 ILogger<CreateShipmentHandler> logger)
7 : IHandler
8{
9 public async Task<Result<ShipmentResponse>> HandleAsync(
10 CreateShipmentRequest request,
11 CancellationToken cancellationToken)
12 {
13 var shipmentExists = await context.Shipments
14 .AnyAsync(x => x.OrderId == request.OrderId, cancellationToken);
15
16 if (shipmentExists)
17 {
18 logger.LogInformation("Shipment for order '{OrderId}' already exists", request.OrderId);
19 return ShipmentErrors.AlreadyExists(request.OrderId);
20 }
21
22 var stockRequest = CreateCheckStockRequest(request);
23
24 var stockResponse = await stockApi.CheckStockAsync(stockRequest, cancellationToken);
25 if (!stockResponse.IsSuccess)
26 {
27 logger.LogInformation("Stock check failed: {@Errors}", stockResponse.Errors);
28 return stockResponse.Errors;
29 }
30
31 var shipmentNumber = new Faker().Commerce.Ean8();
32 var shipment = request.MapToShipment(shipmentNumber);
33
34 await context.Shipments.AddAsync(shipment, cancellationToken);
35 await context.SaveChangesAsync(cancellationToken);
36
37 logger.LogInformation("Created shipment: {@Shipment}", shipment);
38
39 var shipmentCreatedEvent = new ShipmentCreatedEvent(shipment);
40 await eventPublisher.PublishAsync(shipmentCreatedEvent, cancellationToken);
41
42 return shipment.MapToResponse();
43 }
44
45 private static CheckStockRequest CreateCheckStockRequest(CreateShipmentRequest request)
46 {
47 return new CheckStockRequest(
48 request.Items
49 .Select(x => new ProductStock(x.Product, x.Quantity))
50 .ToList()
51 );
52 }
53}
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:
1internal static class ShipmentErrors
2{
3 private const string ErrorPrefix = "Shipments";
4
5 internal static Error NotFound(string shipmentNumber) =>
6 Error.NotFound($"{ErrorPrefix}.{nameof(NotFound)}",
7 $"Shipment with number '{shipmentNumber}' not found");
8
9 internal static Error AlreadyExists(string orderId) =>
10 Error.Conflict($"{ErrorPrefix}.{nameof(AlreadyExists)}",
11 $"Shipment for order '{orderId}' already exists");
12}
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).
1internal static class CreateShipmentMappingExtensions
2{
3 public static Shipment MapToShipment(this CreateShipmentRequest request)
4 {
5 // Mapping code omitted for brevity
6 }
7
8 public static ShipmentResponse MapToResponse(this Shipment shipment)
9 {
10 // Mapping code omitted for brevity
11 }
12}
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:
1public class CreateShipmentRequestValidator : AbstractValidator<CreateShipmentRequest>
2{
3 public CreateShipmentRequestValidator()
4 {
5 RuleFor(shipment => shipment.OrderId).NotEmpty();
6 RuleFor(shipment => shipment.Carrier).NotEmpty();
7 RuleFor(shipment => shipment.ReceiverEmail).NotEmpty();
8 RuleFor(shipment => shipment.Items).NotEmpty();
9
10 RuleFor(shipment => shipment.Address)
11 .Cascade(CascadeMode.Stop)
12 .NotNull()
13 .WithMessage("Address must not be null")
14 .SetValidator(new AddressValidator());
15 }
16}
17
18public class AddressValidator : AbstractValidator<Address>
19{
20 public AddressValidator()
21 {
22 RuleFor(address => address.Street).NotEmpty();
23 RuleFor(address => address.City).NotEmpty();
24 RuleFor(address => address.Zip).NotEmpty();
25 }
26}
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.
1Features/
2βββ CreateShipment/
3βββ DispatchShipment/
4βββ GetShipmentByNumber/
5βββ Shared/
6 βββ Errors/
7 β βββ ShipmentErrors.cs
8 βββ Requests/
9 β βββ ShipmentItemRequest.cs
10 βββ Responses/
11 β βββ ShipmentResponse.cs
12 β βββ ShipmentItemResponse.cs
13 βββ Routes/
14 βββ RouteConsts.cs
For all the API endpoint names, I used a static class called RouteConsts:
1internal static class RouteConsts
2{
3 internal const string BaseRoute = "/api/shipments";
4
5 internal const string GetByNumber = $"{BaseRoute}/{{shipmentNumber}}";
6
7 internal const string CancelShipment = $"{BaseRoute}/cancel/{{shipmentNumber}}";
8
9 internal const string DispatchShipment = $"{BaseRoute}/dispatch/{{shipmentNumber}}";
10 // ... and so on
11}
I wrote a detailed guide on how to share code and avoid code duplication in Vertical Slices. Read it here.
Auto-Registration of Endpoints, Handlers, and Validators
With four files per slice, you would expect a lot of DI registration code.
Instead, I use a small helper that scans the module's assembly and registers everything at startup.
I have small reflection helpers that scan an assembly and register all its members.
1. Endpoints
1public static IServiceCollection RegisterApiEndpointsFromAssemblyContaining(
2 this IServiceCollection services, Type marker)
3{
4 var assembly = marker.Assembly;
5
6 var endpointTypes = assembly.GetTypes()
7 .Where(t => t.IsAssignableTo(typeof(IApiEndpoint))
8 && t is { IsClass: true, IsAbstract: false, IsInterface: false });
9
10 var serviceDescriptors = endpointTypes
11 .Select(type => ServiceDescriptor.Transient(typeof(IApiEndpoint), type))
12 .ToArray();
13
14 services.TryAddEnumerable(serviceDescriptors);
15 return services;
16}
17
18public static WebApplication MapApiEndpoints(this WebApplication app)
19{
20 var endpoints = app.Services.GetRequiredService<IEnumerable<IApiEndpoint>>();
21
22 foreach (var endpoint in endpoints)
23 {
24 endpoint.MapEndpoint(app);
25 }
26
27 return app;
28}
2. Handlers
1public static IServiceCollection RegisterHandlersFromAssemblyContaining(
2 this IServiceCollection services, Type marker)
3{
4 var assembly = marker.Assembly;
5
6 RegisterCommandHandlers(services, assembly);
7 RegisterEventHandlers(services, assembly);
8
9 return services;
10}
11
12private static void RegisterCommandHandlers(IServiceCollection services, Assembly assembly)
13{
14 var handlerTypes = assembly.GetTypes()
15 .Where(t => t is { IsClass: true, IsAbstract: false }
16 && t.IsAssignableTo(typeof(IHandler))
17 && !t.IsAssignableTo(typeof(IEventHandler)))
18 .ToList();
19
20 foreach (var implementationType in handlerTypes)
21 {
22 var interfaceType = implementationType.GetInterfaces()
23 .FirstOrDefault(i => i != typeof(IHandler) && i.IsAssignableTo(typeof(IHandler)));
24
25 if (interfaceType is not null)
26 {
27 services.AddScoped(interfaceType, implementationType);
28 }
29 }
30}
Cross-Slice Side Effects via Events
Slices and modules can communicate via events and method calls.
For example, for a "Create Shipment", we need to:
- Update stock levels in the Stocks module
- Register the shipment with a carrier in the Carriers module
Each event and handler is a separate file inside the slice's Events/ folder:
1public sealed class UpdateStockEventHandler(
2 IStockModuleApi stockApi,
3 ILogger<UpdateStockEventHandler> logger)
4 : IEventHandler<ShipmentCreatedEvent>
5{
6 public async Task HandleAsync(ShipmentCreatedEvent @event, CancellationToken cancellationToken)
7 {
8 logger.LogInformation("Updating stock for order {OrderId}", @event.Shipment.OrderId);
9
10 var updateRequest = CreateDecreaseStockRequest(@event.Shipment);
11 var response = await stockApi.DecreaseStockAsync(updateRequest, cancellationToken);
12
13 if (!response.IsSuccess)
14 {
15 logger.LogError("Failed to update stock for order {OrderId}: {@Errors}",
16 @event.Shipment.OrderId, response.Errors);
17
18 throw new Exception($"Failed to update stock: {response.Errors}");
19 }
20
21 logger.LogInformation("Successfully updated stock for order {OrderId}", @event.Shipment.OrderId);
22 }
23
24 private static DecreaseStockRequest CreateDecreaseStockRequest(Shipment shipment)
25 {
26 return new DecreaseStockRequest(
27 Products: shipment.Items
28 .Select(x => new ProductStock(x.Product, x.Quantity))
29 .ToList()
30 );
31 }
32}
The handler dispatches events through IEventPublisher.
Inter-Module Communication via PublicApi
In a Modular Monolith, modules must not reach into each other's internals.
They communicate only through a public interface or an event.
For each module, I have a separate project named Modules.{Module}.PublicApi. This project contains:
- The interface that other modules use to call the module
- The request and response records used by that interface
Here is the Stocks module's PublicApi:
1public interface IStockModuleApi
2{
3 Task<Result<Success>> CheckStockAsync(
4 CheckStockRequest request,
5 CancellationToken cancellationToken);
6
7 Task<Result<Success>> DecreaseStockAsync(
8 DecreaseStockRequest request,
9 CancellationToken cancellationToken);
10}
The Shipments module references only Modules.Stocks.PublicApi.
It cannot reference the Stocks domain entities, DbContext, or internal services.
The implementation of IStockModuleApi lives inside the Stocks module and is internal sealed.
This gives you the separation benefits of microservices (clear contracts, no shared internals) while keeping the simplicity of a single deployable application.
If you ever extract a module into its own service, the PublicApi contract remains unchanged, and only the implementation of underlying transport changes.
If you need to read data or perform transactions across multiple modules, I created a detailed guide outlining the trade-offs involved.
Read it here.
Summary
This is the slice layout I now use in every new .NET project:
- One folder per feature, named after the use case
- Four files per slice:
Endpoint, Handler, Mapping, Validators
- Optional
Events/ subfolder when the slice raises events
- Manual handlers via an
IHandler marker interface without extra interfaces and MediatR
- Minimal API endpoints via an
IApiEndpoint marker interface
- FluentValidation called explicitly in the endpoint
- Direct
DbContext in handlers without repositories
Result<T> for business errors instead of exceptions
- A
Shared/ folder per module for cross-slice things
- Cross-module calls only through
PublicApi projects
- Auto-registration of endpoints, handlers
In practice, this layout is fast to navigate, predictable across the team, easy to debug (no decorators, no magic), simple to test, and free of too many 3rd party dependencies.