In the rapidly evolving landscape of software development, maintaining agility and scalability is paramount. In this post, we’ll delve into the Vertical Slice approach to software design within ASP.NET Core — a modern alternative to the traditional layered architecture. Unlike conventional methods that compartmentalize code by technical roles, Vertical Slices organize code by feature, enhancing maintainability and reducing complexity. We’ll explore what Vertical Slices are, compare them to layered architectures, and walk through their implementation using essential tools like MediatR, FluentValidation, and OneOf. By the end, you’ll have a practical template to adopt this approach, streamlining your development process.

Vertical Slices vs Layered Architecture

Traditionally, many applications have been built using the Layered Architecture Pattern, also known as N-Tier Architecture. This approach structures an application into distinct layers, typically including Presentation, Application, Domain, and Data Access layers. Variants of this pattern, such as Clean Architecture, have gained popularity within the .NET community due to their focus on separation of concerns and testability.

Vertical Slices vs Layered Architecture While layered architectures offer clear separation they often become verbose and cumbersome to work with. Developing a new feature typically requires modifications across all layers, which increases the complexity of a task. This multi-layered approach can slow down development cycles and increase the risk of introducing errors or bugs, as changes in one layer may affect others.

Vertical Slices aim to address these shortcomings by organizing the application around features rather than layers. Instead of separating code by its technical role, vertical slices group all necessary components - ranging from data access and business logic to the user interface - within the same boundary.

A Deep Dive into Vertical Slices

Vertical Slices encapsulate all the elements required to implement a specific feature within a single, cohesive module. This includes:

  • Endpoints: Define how the feature is exposed to clients.
  • Request/Response Models: Structure the data flow for the feature.
  • Validation: Ensure data integrity and business rules.
  • Business Logic: Handle the core functionality.
  • Data Access: Interact with the database or other data sources.
  • Mapping: Translate between different data representations.

Benefits of Vertical Slices

  1. Modularity: Each feature is self-contained, making the codebase easier to navigate and manage.
  2. Scalability: New features can be added with minimal impact on existing code.
  3. Maintainability: Bugs and enhancements are easier to locate and address within a specific slice.
  4. Parallel Development: Teams can work on different slices simultaneously without stepping on each other’s toes.

Implementing Vertical Slices

There are multiple ways to implement Vertical Slices in your application. In this post, I’ll share an opinionated approach that leverages popular frameworks within the .NET ecosystem. The key frameworks we’ll utilize are:

  • MediatR : Facilitates the implementation of the CQRS pattern, allowing for clear separation of commands and queries.
  • FluentValidation : Provides a fluent interface for defining and enforcing validation rules.
  • OneOf : Implements discriminated unions, enabling flexible result handling.
  • AutoMapper : Simplifies object-object mapping, reducing boilerplate code.

By integrating these frameworks, we can build a robust Vertical Slice architecture that is both scalable and maintainable.

CQRS with MediatR

Command Query Responsibility Segregation (CQRS) is a pattern that separates read and write operations into distinct models. This separation aligns perfectly with the Vertical Slice approach, where each feature handles its own commands and queries independently.

MediatR is a widely-used framework that facilitates the implementation of CQRS by acting as an in-process messaging mediator. It allows us to define Commands (for write operations) and Queries (for read operations), ensuring that each feature maintains a single responsibility.

In our Vertical Slice setup each feature will have its own command or query handled by its dedicated handler. This encapsulates the feature’s logic, ensuring that it is isolated and can be changed independently.

Model Validation using FluentValidation

Ensuring that incoming requests meet specific criteria is crucial for maintaining application integrity. FluentValidation offers a fluent interface for defining validation rules, allowing developers to express complex validation logic in a readable and maintainable manner.

FluentValidation also works seamlessly with MediatR pipeline behaviors so we can automate the validation process.

Result Handling with OneOf

Handling various outcomes of operations — successes, failures, or exceptions — is a common challenge in software development. The Result Pattern leverages discriminated unions to represent these different outcomes explicitly. In our approach, we’ll use OneOf to implement this pattern effectively.

By using OneOf we ensure that we handle all possible result types and that we clearly distinguish between successful results and errors. Here is an example of how we can structure our error handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public record ErrorsResult(IEnumerable<Error> Errors, string StatusCode){
  public IResult ToTypedResult() =>
    StatusCode switch
    {
        "400" => TypedResults.BadRequest(this),
        "401" => TypedResults.Unauthorized(),
        "403" => TypedResults.Forbid(),
        "404" => TypedResults.NotFound(this),
        "409" => TypedResults.Conflict(this),
        _ => TypedResults.BadRequest(this)
    };
}

public record Error(string ErrorMessage);

Object Mapping with AutoMapper

When using AutoMapper, we define how the domain model maps to the response model. This mapping ensures that only the necessary fields are exposed in the response, maintaining a clear contract with API consumers.

By leveraging AutoMapper, we can streamline the transformation of data between different layers, reducing boilerplate code and minimizing the risk of errors.

Putting It All Together

To illustrate the Vertical Slice approach, let’s build a feature within a Book Store API that retrieves a book by its ID. This example will demonstrate how each component—Request/Response models, validation, mapping, and handling—is encapsulated within a single slice.

Step 1: Define Request and Response Models

First, we’ll define the models that represent the incoming request and the outgoing response.

1
2
3
public record GetBookByIdRequest(string Id) : IRequest<OneOf<GetBookByIdResponse, ErrorsResult>>;

public record GetBookByIdResponse(string Id, string BookName, string Author, DateTime PublishedAt);
  • GetBookByIdRequest: Represents the query to retrieve a book by its ID.
  • GetBookByIdResponse: Defines the structure of the successful response containing book details.

Step 2: Implement Validation

Next, we’ll create a validator to ensure that the incoming Id is valid.

1
2
3
4
5
6
7
8
9
public class GetBookByIdValidator : AbstractValidator<GetBookByIdRequest>
{
    public GetBookByIdValidator()
    {
        RuleFor(e => e.Id)
            .NotEmpty().WithMessage("Book ID cannot be empty.")
            .Matches("^\\d+$").WithMessage("Book ID must be a numeric value.");
    }
}

This validator ensures that the Id is neither empty nor non-numeric, preventing invalid requests from reaching the business logic.

Step 3: Create the Request Handler

The handler contains the core logic to process the request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class GetBookByIdRequestHandler : IRequestHandler<GetBookByIdRequest, OneOf<GetBookByIdResponse, ErrorsResult>>
{
    private readonly IMapper _mapper;
    private readonly BookStoreContext _context;

    public GetBookByIdRequestHandler(IMapper mapper, BookStoreContext context)
    {
        _mapper = mapper;
        _context = context;
    }

    public async Task<OneOf<GetBookByIdResponse, ErrorsResult>> Handle(GetBookByIdRequest request, CancellationToken cancellationToken)
    {
        var book = await _context.Books
            .FindAsync([request.Id], cancellationToken);

        if (book is null)
        {
            return new ErrorsResult([new Error("Book not found")], "404");
        }

        return _mapper.Map<GetBookByIdResponse>(book);
    }
}
  • Data Retrival: Attempts to find the book in the database.
  • Error Handling: Returns an ErrorsResult if the book isn’t found.
  • Success Response: Maps and returns the book details using AutoMapper.

Step 4: Define the Mapping Profile:

Using AutoMapper, we define how the domain model maps to the response model.

1
2
3
4
5
6
7
public class GetBookByIdMapping : Profile
{
    public GetBookByIdMapping()
    {
        CreateMap<Book, GetBookByIdResponse>();
    }
}

This mapping ensures that only the necessary fields are exposed in the response, maintaining a clear contract with API consumers.

Step 5: Expose the Endpoint:

Finally, we’ll define the API endpoint using Minimal APIs, keeping the feature encapsulated within its slice.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class GetBookByIdEndpoint : IEndpoint
{
    public RouteHandlerBuilder Map(IEndpointRouteBuilder app) =>
        app.MapGet("/books/{id}", GetBookById)
            .Produces<GetBookByIdResponse>()
            .Produces<ErrorsResult>(404)
            .WithOpenApi();

    private static async Task<IResult> GetBookById(HttpContext context, string id, IMediator mediator)
    {
        var request = new GetBookByIdRequest(id);

        var response = await mediator.Send(request);
        return response.Match<IResult>(
            TypedResults.Ok,
            error => error.ToTypedResult()
        );
    }
}

This endpoint seamlessly integrates with the Vertical Slice, handling requests and responses efficiently while maintaining clear separation from other features.

MediatR Pipeline Behaviors

Pipeline behaviors in MediatR act as middleware, allowing you to inject additional processing steps around your request handlers. They are instrumental in implementing cross-cutting concerns such as logging, validation, and exception handling within the Vertical Slice architecture.

We can utilize this to automate the validation process by invoking all validators associated with a request before it reaches the handler.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public sealed class ValidationPipelineBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : notnull
    where TResponse : IOneOf
{
    private static bool s_implicitConversionChecked;
    private static Func<ErrorsResult, TResponse>? s_implicitConversionFunc;

    private readonly IEnumerable<IValidator> _validators;

    public ValidationPipelineBehaviour(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (s_implicitConversionFunc is null && !s_implicitConversionChecked)
        {
            var responseType = typeof(TResponse);

            if (responseType.IsGenericType &&
                responseType.GenericTypeArguments.Any(t => t == typeof(ErrorsResult)))
            {
                var implicitConversionMethod = responseType.GetMethod("op_Implicit", [typeof(ErrorsResult)]);

                if (implicitConversionMethod is not null)
                {
                    var errorsParam = Expression.Parameter(typeof(ErrorsResult), "e");
                    s_implicitConversionFunc =
                        Expression.Lambda<Func<ErrorsResult, TResponse>>(
                                Expression.Call(implicitConversionMethod, errorsParam),
                                errorsParam)
                            .Compile();
                }
            }

            s_implicitConversionChecked = true;
        }

        if (s_implicitConversionFunc is not null)
        {
            var context = new ValidationContext<TRequest>(request);

            var validationResults =
                await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
            var validationResult = new ValidationResult(validationResults);

            if (!validationResult.IsValid)
            {
                var errors = validationResult.Errors
                    .Select(e => new Error(e.ErrorMessage));

                return s_implicitConversionFunc(new ErrorsResult(errors, "400"));
            }
        }

        return await next()
            .ConfigureAwait(false);
    }
}

Visual Studio Scaffolding Template

To streamline the creation of new vertical slices, we can leverage Visual Studio’s scaffolding capabilities by creating a custom template. This template will auto-generate the necessary classes for each new feature, ensuring consistency and saving development time.

In order to create the template create the following files in a folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Feature.cs

using AutoMapper;

using FluentValidation;

using MediatR;

using OneOf;

namespace $rootnamespace$;

public record $safeitemname$Request() : IRequest<OneOf<$safeitemname$Response, ErrorsResult>>;

public record $safeitemname$Response();

public class $safeitemname$RequestHandler : IRequestHandler<$safeitemname$Request, OneOf<$safeitemname$Response, ErrorsResult>>
{
    private readonly IMapper _mapper;

    public $safeitemname$RequestHandler(IMapper mapper)
    {
        _mapper = mapper;
    }

    public async Task<OneOf<$safeitemname$Response, ErrorsResult>> Handle($safeitemname$Request request, CancellationToken cancellationToken)
    {
        return default;
    }
}

public class $safeitemname$Endpoint : IEndpoint
{
    public RouteHandlerBuilder Map(IEndpointRouteBuilder app) =>
        app.MapGet("/$safeitemname$", $safeitemname$)
            .Produces<$safeitemname$Response>()
            .WithOpenApi();

    private static async Task<IResult> $safeitemname$(HttpContext context, IMediator mediator)
    {
        var request = new $safeitemname$Request();

        var response = await mediator.Send(request);
        return response.Match<IResult>(
            TypedResults.Ok,
            error => error.ToTypedResult()
        );
    }
}

public class $safeitemname$Mapping : Profile
{
    public $safeitemname$Mapping()
    {
    }
}

public class $safeitemname$Validator : AbstractValidator<$safeitemname$Request>
{
    public $safeitemname$Validator()
    {
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<!-- Feature.vstemplate -->
<VSTemplate Version="3.0.0" xmlns="http://schemas.microsoft.com/developer/vstemplate/2005" Type="Item">
  <TemplateData>
    <DefaultName>Feature.cs</DefaultName>
    <Name>CQRS Feature</Name>
    <Description>CQRS Feature using Mediatr</Description>
    <ProjectType>CSharp</ProjectType>
    <TemplateGroupID>AspNetCore</TemplateGroupID>
    <TemplateID>AspNetCore.Feature</TemplateID>
    <NumberOfParentCategoriesToRollUp>2</NumberOfParentCategoriesToRollUp>
    <SortOrder>10</SortOrder>
    <ShowByDefault>false</ShowByDefault>
  </TemplateData>
  <TemplateContent>
    <References />
    <ProjectItem SubType="" TargetFileName="$safeitemname$.cs" ReplaceParameters="true">Feature.cs</ProjectItem>
  </TemplateContent>
</VSTemplate>

Now all you need to do now is to zip the folder and place it in your Visual Studio templates folder which you can find in the Visual Studio setting User item template location.

Conclusion

The Vertical Slice approach offers an alternative to traditional layered architectures, promoting modularity, scalability, and maintainability by organizing code around features rather than technical layers. By leveraging tools like MediatR, FluentValidation, OneOf, and AutoMapper, developers can implement Vertical Slices effectively within ASP.NET Core applications.

In this post we have explored:

  • How Vertical Slices differ from layered architectures and what benefits they bring.
  • Modern frameworks to easily build feature-centric slices.
  • A practical example of how to get started with Vertical Slices.
  • Pipeline behaviors and templates to accelerate development.

To access the full code from this post, visit the GitHub - Book Store API Example repository.

Adopting the Vertical Slice approach can lead to more organized codebases, easier implementation and maintenance and overall faster development cycles. Whether you’re starting a new project or refactoring an existing one, considering Vertical Slices could significantly enhance your application’s architecture.

Have you implemented Vertical Slices in your projects? Share your experiences or questions in the comments below!