Vertical Slice Architecture In .NET using Cortex.Mediator and Minimal APIs
Vertical Slice Architecture (VSA) has gained traction as an antidote to traditional layered architectures, where changes often ripple across multiple files and folders. Instead of organizing code by technical concerns (Controllers, Services, Repositories), VSA groups **everything related to a feature** in one cohesive “slice.” This approach reduces coupling, improves maintainability, and aligns with business requirements.
Pair this with Cortex.Mediator (for CQRS) and Minimal APIs (for lightweight endpoints), and you have a recipe for clean, scalable, and focused .NET applications. Let’s explore how.
How Cortex.Mediator Makes It Easier
Cortex.Mediator (part of the Cortex Data Framework) helps you apply the Mediator pattern to each vertical slice:
- ICommand / ICommandHandler<TCommand> for operations that change state.
- IQuery<TResult> / IQueryHandler<TQuery, TResult> for read operations.
- INotification / INotificationHandler<TNotification> for event-style communication.
Out-of-the-box Pipeline Behaviors
When you register Cortex.Mediator with .AddDefaultBehaviors()
, it automatically provides:
- Validation: FluentValidation is applied to your commands/queries.
- Logging: The library logs each operation and captures exceptions.
- Transaction: Commands can be wrapped in a transaction if you use an
IUnitOfWork
IDbConnection approach.
With these concerns handled centrally, each vertical slice remains focused on feature logic, not boilerplate.
Folder Layout Example
Here’s a sketch of how you might structure a Users feature:
src/
Features/
Users/
CreateUser/
CreateUserCommand.cs
CreateUserCommandHandler.cs
CreateUserValidator.cs
CreateUserEndpoint.cs // Minimal API route
GetUser/
GetUserQuery.cs
GetUserQueryHandler.cs
GetUserValidator.cs
GetUserEndpoint.cs // Another route
Program.cs
You repeat this pattern for each feature, such as Orders/PlaceOrder/
or Products/UpdateInventory/
. Each folder is a self-contained vertical slice.
Registration in Program.cs
Let’s assume you’re using a .NET 6+ style Program.cs
with minimal hosting:
var builder = WebApplication.CreateBuilder(args);
// 1. Register DB or other dependencies (optional)
// builder.Services.AddScoped<IDbConnection>(...);
// 2. Add Cortex.Mediator
builder.Services.AddCortexMediator(
configuration: builder.Configuration,
handlerAssemblyMarkerTypes: new[] { typeof(Program) }, // your assembly
configure: options =>
{
// This enables built-in logging, validation, and transaction behaviors
options.AddDefaultBehaviors();
}
);
var app = builder.Build();
// 3. Map your endpoints (for each slice)
app.MapCreateUserEndpoint(); // calls a method in CreateUserEndpoint.cs
app.MapGetUserEndpoint(); // calls a method in GetUserEndpoint.cs
app.Run();
A Typical Command Slice: “CreateUser”
Command Definition
// CreateUserCommand.cs
using Cortex.Mediator.Commands;
public class CreateUserCommand : ICommand
{
public string UserName { get; set; }
public string Email { get; set; }
}
Handler
// CreateUserCommandHandler.cs
using Cortex.Mediator.Commands;
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand>
{
public async Task Handle(CreateUserCommand command, CancellationToken cancellationToken)
{
// E.g., insert user into your DB
// _db.Users.Add(new User { UserName = command.UserName, Email = command.Email });
// await _db.SaveChangesAsync();
Console.WriteLine($"[CreateUser] User '{command.UserName}' created.");
}
}
Validator (Optional)
// CreateUserValidator.cs
using FluentValidation;
public class CreateUserValidator : AbstractValidator<CreateUserCommand>
{
public CreateUserValidator()
{
RuleFor(x => x.UserName).NotEmpty();
RuleFor(x => x.Email).NotEmpty().EmailAddress();
}
}
Minimal API Endpoint
// CreateUserEndpoint.cs
using Cortex.Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
public static class CreateUserEndpoint
{
public static void MapCreateUserEndpoint(this WebApplication app)
{
app.MapPost("/users/create", async (CreateUserCommand cmd, IMediator mediator) =>
{
// Pipeline behaviors handle validation, logging, transactions automatically
await mediator.SendAsync(cmd);
return Results.Ok("User created successfully!");
});
}
}
By grouping the command, handler, validator, and endpoint in the same folder, you have a clear, cohesive “slice.”
A Typical Query Slice: “GetUser”
Query Definition
// GetUserQuery.cs
using Cortex.Mediator.Queries;
public class GetUserQuery : IQuery<GetUserResponse>
{
public int UserId { get; set; }
}
Query Handler & Response
// GetUserQueryHandler.cs
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, GetUserResponse>
{
public async Task<GetUserResponse> Handle(GetUserQuery query, CancellationToken cancellationToken)
{
// E.g., retrieve from DB
// var user = _db.Users.Find(query.UserId);
return new GetUserResponse
{
UserId = query.UserId,
UserName = "Alice",
Email = "alice@example.com"
};
}
}
public class GetUserResponse
{
public int UserId { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
}
Validator (Optional)
// GetUserValidator.cs
using FluentValidation;
public class GetUserValidator : AbstractValidator<GetUserQuery>
{
public GetUserValidator()
{
RuleFor(q => q.UserId).GreaterThan(0);
}
}
Minimal API Endpoint
// GetUserEndpoint.cs
using Cortex.Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
public static class GetUserEndpoint
{
public static void MapGetUserEndpoint(this WebApplication app)
{
app.MapGet("/users/{id}", async (int id, IMediator mediator) =>
{
var user = await mediator.SendAsync<GetUserQuery, GetUserResponse>(
new GetUserQuery { UserId = id }
);
return user != null ? Results.Ok(user) : Results.NotFound();
});
}
}
How Cortex.Mediator Handles Cross-Cutting Concerns
Validation
If you have a CreateUserValidator
, Cortex.Mediator’s ValidationCommandBehavior automatically runs it before the command is passed to CreateUserCommandHandler
. If any validation fails, it throws an exception with details—no need to clutter your code with manual checks.
Logging
Using the default behaviors, each command or query is logged when it starts and if an exception occurs. This saves you from sprinkling logging code in each endpoint or handler.
Transaction
If you configure an IUnitOfWork
or a DB transaction approach, the TransactionCommandBehavior automatically starts, commits, or rolls back transactions for your commands. That means each feature’s data operations remain consistent if something fails.
Why Vertical Slice + Cortex.Mediator?
- Feature-Centric: Each slice includes exactly what’s needed for that operation, from the input model (command/query) to the endpoint that receives it.
- Minimal Repetition: Logging, validation, and transactions are globally applied by the mediator pipeline, so each slice only focuses on actual business logic.
- Easy to Evolve: Adding or modifying a feature rarely affects other slices.
- Scalable: As your application grows, you can keep adding slices. Each slice remains self-contained.
Conclusion
Vertical Slice Architecture keeps your solution organized around features, while Cortex.Mediator ensures cross-cutting aspects are handled out-of-the-box. Combining them:
- Minimizes overhead in each slice (no repeated validation or logging code).
- Keeps commands, queries, and endpoints near each other, simplifying development.
- Maintains a clean separation so each slice evolves independently.
If you’re looking to simplify your .NET apps — especially with minimal APIs or modular designs — try grouping your features in vertical slices and letting Cortex.Mediator handles the mediator pattern seamlessly behind the scenes!