Overview
Among developers striving to build maintainable, scalable, and testable applications, vertical slice architecture has gained traction in recent years. This architecture advocates organizing code by features instead of horizontally dividing layers (such as presentation, business logic, and data access). Each slice (or feature) contains all the code it requires.
Here, we'll explore the vertical slice architecture, focusing on how it's implemented in C# 12. We'll go over its principles and provide code examples to demonstrate how to apply it effectively.
What is Vertical Slice Architecture?
The vertical slice architecture divides an application into components (slices) that are independently developed and maintained. In contrast to the traditional layered architecture, where concerns are separated across the entire application horizontally, each slice encompasses the entire stack, from the user interface (UI) down to the database.
Key Benefits
- Improved Cohesion: The features are self-contained, making management and understanding easier.
- Reduced Coupling: Flexibility and maintainability are enhanced by reducing coupling between features.
- Focused Testing: Having independent slices allows for more focused and isolated testing.
- Easier Collaboration: Different teams can work on different slices without stepping on each other's toes.
Implementing Vertical Slice Architecture in C# 12
Using a C# 12 application, we'll explore how to implement a vertical slice architecture. For the purpose of demonstration, we'll create a simple e-commerce application with product management features.
Project Structure
This is an example of a vertical slice architecture project structure:
ECommerceApp
│
├── src
├── Common
│ │ ├── NotFoundException.cs
├── Controllers
│ │ ├── OrdersController.cs
│ │ ├── ProductsController.cs
├── Infrastructure
│ │ ├── AppDbContext.cs
│ ├── Products
│ │ ├── AddProduct
│ │ │ ├── AddProductCommand.cs
│ │ │ ├── AddProductHandler.cs
│ │ │ ├── AddProductResponse.cs
│ │ ├── GetProduct
│ │ │ ├── GetProductQuery.cs
│ │ │ ├── GetProductHandler.cs
│ │ │ ├── GetProductResponse.cs
│ │ ├── Common
│ │ │ ├── IProductRepository.cs
│ │ │ ├── ProductRepository.cs
│ │ │ └── Product.cs
│ ├── Orders
│ │ ├── GetOrder
│ │ │ ├── GetOrderCommand.cs
│ │ │ ├── GetOrderHandler.cs
│ │ │ ├── GetOrderResponse.cs
│ │ ├── PlaceOrder
│ │ │ ├── PlaceOrderCommand.cs
│ │ │ ├── PlaceOrderHandler.cs
│ │ │ ├── PlaceOrderResponse.cs
│ │ ├── Common
│ │ │ ├── IOrderRepository.cs
│ │ │ ├── OrderRepository.cs
│ │ │ └── Order.cs
│ ├── Controllers
│ │ ├── ProductsController.cs
│ │ └── OrdersController.cs
│ └── ECommerceApp.csproj
└── Program.cs
Feature slices (e.g., Products, Orders) are further divided into sub-features (e.g., GetProduct, AddProduct). This structure ensures that every slice contains all of the commands, handlers, and responses it needs.
Command
As part of vertical slice architecture, we use commands to encapsulate requests for actions. Consider the AddProductCommand and PlaceOrderCommand:
AddProductCommand.cs
using MediatR;
namespace ECommerceApp.Products.AddProduct;
public record AddProductCommand(string Name, decimal Price, string Description) : IRequest<AddProductResponse>;
PlaceOrderCommand.cs
using MediatR;
namespace ECommerceApp.Orders.PlaceOrder;
public record PlaceOrderCommand(string CustomerName, decimal TotalAmount) : IRequest<PlaceOrderResponse>;
With C# 12, we can use record types to define immutable command objects, providing a concise syntax.
Handler
In the next step, we need a handler to process the command. This is where the logic for adding products and orders resides.
AddProductHandler.cs
using ECommerceApp.Products.Common;
using MediatR;
namespace ECommerceApp.Products.AddProduct;
public class AddProductHandler : IRequestHandler<AddProductCommand, AddProductResponse>
{
private readonly IProductRepository _productRepository;
public AddProductHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task<AddProductResponse> Handle(AddProductCommand request, CancellationToken cancellationToken)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
Description = request.Description
};
await _productRepository.AddProductAsync(product);
return new AddProductResponse(product.Id, product.Name);
}
}
PlaceOrderHandler.cs
using ECommerceApp.Orders.Common;
using MediatR;
namespace ECommerceApp.Orders.PlaceOrder;
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResponse>
{
private readonly IOrderRepository _orderRepository;
public PlaceOrderHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<PlaceOrderResponse> Handle(PlaceOrderCommand request, CancellationToken cancellationToken)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerName = request.CustomerName,
TotalAmount = request.TotalAmount,
OrderDate = DateTime.UtcNow
};
await _orderRepository.AddOrderAsync(order);
return new PlaceOrderResponse(order.Id, order.CustomerName);
}
}
The AddProductHandler and PlaceOrderHandler takes a command as input and returns a response using the IRequestHandler from the MediatR library.
Response
An object containing the result of the command execution is called a response object.
AddProductResponse.cs
namespace ECommerceApp.Products.AddProduct;
public record AddProductResponse(Guid ProductId, string ProductName);
PlaceOrderResponse.cs
namespace ECommerceApp.Orders.PlaceOrder;
public record PlaceOrderResponse(Guid OrderId, string CustomerName);
In C# 12, record types provide an immutable data structure that is ideal for encapsulating responses.
Repository
In addition, the repository pattern is typically used for interacting with the data store. Here is a simple example:
IProductRepository.cs
namespace ECommerceApp.Products.Common;
public interface IProductRepository
{
Task AddProductAsync(Product product);
Task<Product?> GetProductByIdAsync(Guid id);
}
ProductRepository.cs
using ECommerceApp.Infrastructure;
namespace ECommerceApp.Products.Common;
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task AddProductAsync(Product product)
{
_context.Products.Add(product);
await _context.SaveChangesAsync();
}
public async Task<Product?> GetProductByIdAsync(Guid id) => await _context.Products.FindAsync(id);
}
Product.cs
namespace ECommerceApp.Products.Common;
public class Product
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; }=string.Empty;
public decimal Price { get; set; }
public string Description { get; set; } = string.Empty;
}
IOrderRepository.cs
namespace ECommerceApp.Orders.Common;
public interface IOrderRepository
{
Task AddOrderAsync(Order order);
Task<Order?> GetOrderByIdAsync(Guid id);
}
OrderRepository.cs
using Microsoft.EntityFrameworkCore;
namespace ECommerceApp.Orders.Common;
public class OrderRepository : IOrderRepository
{
private readonly DbContext _context;
public OrderRepository(DbContext context)
{
_context = context;
}
public async Task AddOrderAsync(Order order)
{
await _context.Set<Order>().AddAsync(order);
await _context.SaveChangesAsync();
}
public async Task<Order?> GetOrderByIdAsync(Guid id)
{
return await _context.Set<Order>().FindAsync(id);
}
}
Order.cs
namespace ECommerceApp.Orders.Common;
public class Order
{
public Guid Id { get; set; } = Guid.NewGuid();
public string CustomerName { get; set; }=string.Empty;
public decimal TotalAmount { get; set; }
public DateTime OrderDate { get; set; }
}
API Controllers
With MediatR, we can manage orders and products.
The API controllers serve as entry points for the application, and each feature in the vertical slice architecture has its own self-contained "slice" encapsulating all the logic it requires.
To ensure that the logic for each feature is isolated, the ProductsController interfaces with MediatR to handle actions like adding and retrieving products. The OrdersController communicates with MediatR to place orders and retrieve order details. It follows the same structure for managing order-related actions. It ensures that each slice is independent, whether for products or orders, making the application more maintainable and scalable.
By using the [ApiController] attribute, both the ProductsController and OrdersController classes follow the conventions of ASP.NET Core. By using this attribute, controllers can focus on processing HTTP requests and interacting with MediatR pipelines instead of validating and handling errors.
A MediatR integration occurs in both controllers, where MediatR handles requests (commands and queries) through handlers. By following this pattern, controllers remain lean, as they only route HTTP requests, leaving feature-specific handlers to handle business logic.
In the ProductsController, product-related requests, such as adding products or retrieving products by ID, are handled. As an example, the controller accepts an AddProductCommand to request product details via the AddProduct endpoint. The 201 Created response is returned by the controller using the CreatedAtAction() method after the MediatR pipeline processes the request.
In the same way, GetProductById retrieves product details based on a given ID. If the product exists, a 200 OK response is returned; otherwise, a 404 Not Found response is returned.
ProductsController.cs
using ECommerceApp.Products.AddProduct;
using ECommerceApp.Products.GetProduct;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ECommerceApp.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetProductById(Guid id)
{
var query = new GetProductQuery(id);
var product = await _mediator.Send(query);
if (product == null)
{
return NotFound($"Product with ID {id} not found.");
}
return Ok(product);
}
[HttpPost]
public async Task<IActionResult> AddProduct([FromBody] AddProductCommand command)
{
if (command == null)
{
return BadRequest("Invalid product details.");
}
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetProductById), new { id = result.ProductId }, result);
}
}
An OrdersController manages orders using the same structure as a ProductController. It has the following endpoints:
After validating the input, the PlaceOrderCommand is passed to the mediator for processing. PlaceOrder Endpoint: The PlaceOrder action receives order details through a PlaceOrderCommand. As soon as a new order is created, it returns a 201 Created response with its details via CreatedAtAction().
The GetOrderById action retrieves an order by its ID by sending a GetOrderQuery to the mediator. If the order exists, a 200 OK response is returned with the order details; otherwise, a 404 Not Found response is returned.
OrdersController.cs
using ECommerceApp.Orders.GetOrder;
using ECommerceApp.Orders.PlaceOrder;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ECommerceApp.Controllers;
[Route("api/[controller]")]
[ApiController]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderCommand command)
{
if (command == null)
{
return BadRequest("Invalid order details.");
}
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetOrderById), new { id = result.OrderId }, result);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetOrderById(Guid id)
{
var query = new GetOrderQuery(id);
var order = await _mediator.Send(query);
if (order == null)
{
return NotFound($"Order with ID {id} not found.");
}
return Ok(order);
}
}
Benefits of Vertical Slice Architecture
The ProductsController and OrdersController follow the vertical slice architecture principle by encapsulating business logic into specific slices. As a result, the controllers can concentrate on handling HTTP requests and responses, and the business logic can remain contained within each feature slice while keeping the controllers focused on handling HTTP requests and responses.
Using this structure makes it easier to test each slice independently. For example, if you want to test the logic of PlaceOrderCommand or GetOrderQuery, you don't have to interact directly with the API controller. The modular nature of vertical slice architecture also supports the reuse of business logic across different features while promoting clean and maintainable code.
As our project grows, this architecture ensures scalability, flexibility, and maintainability by organizing our code around features like "Products" and "Orders," and using MediatR to handle communication between layers. By separating concerns into vertical slices, the codebase can be extended and managed more easily.
Wiring It All Together
It is now possible to configure your application to use the slices. ASP.NET Core applications typically register the handlers and repositories in Startup.cs or Program.cs, depending on the version.
Program.cs
using ECommerceApp.Infrastructure;
using ECommerceApp.Orders.Common;
using ECommerceApp.Products.Common;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DbDemoConnection")));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI();
}
else
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
An example of mapping a command to an HTTP endpoint, sending it through MediatR, and returning the response can be seen in this example.
Summary
Through the vertical slice architecture in C #12, we can achieve better modularity, testability, and scalability in your applications. We can achieve better modularity, testability, and scalability by organizing code around features rather than layers. C# 12's record types and MediatR library simplify implementing this architecture.
As part of this article, we covered vertical slice architecture fundamentals, provided a detailed project structure, and provided code examples for implementing it in a C# 12 application. Our applications will likely become easier to manage and extend as you adopt this approach.
I have uploaded the source code for this article to my GitHub Repositor, and I would value your support if you could like this article and also follow me on LinkedIn as it motivates me to write future articles.