Skip to main content

What are Vertical Slices?

Vertical slice architecture organizes code by features rather than technical layers. Each feature is a self-contained vertical slice that includes all the code needed to implement that feature.

Traditional Layers vs Vertical Slices

❌ Horizontal Layers

Controllers/
  UserController.cs
  GroupController.cs
Services/
  UserService.cs
  GroupService.cs
Repositories/
  UserRepository.cs
  GroupRepository.cs
Code for one feature is scattered across multiple folders.

✅ Vertical Slices

Features/
  CreateUser/
    Command.cs
    Handler.cs
    Validator.cs
    Endpoint.cs
  CreateGroup/
    Command.cs
    Handler.cs
    Validator.cs
    Endpoint.cs
Everything for one feature lives in one folder.

Feature Folder Structure

Every feature follows this consistent pattern:
Modules/Identity/Features/v1/Groups/CreateGroup/
├── CreateGroupCommandHandler.cs    ← Business logic
├── CreateGroupCommandValidator.cs  ← Validation rules
└── CreateGroupEndpoint.cs          ← HTTP endpoint mapping

Modules.Identity.Contracts/v1/Groups/CreateGroup/
└── CreateGroupCommand.cs           ← Contract (ICommand<GroupDto>)
1

Command/Query Contract

Defines the request shape and return type. Lives in .Contracts project.
2

Handler

Contains all business logic. Lives in .Features folder.
3

Validator

FluentValidation rules. Lives alongside the handler.
4

Endpoint

HTTP mapping using Minimal APIs. Lives alongside the handler.

Real Example: CreateGroup Feature

Let’s walk through a complete feature implementation from the Identity module.

1. Command Contract

Modules.Identity.Contracts/v1/Groups/CreateGroup/CreateGroupCommand.cs
using FSH.Modules.Identity.Contracts.DTOs;
using Mediator;

namespace FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;

public sealed record CreateGroupCommand(
    string Name,
    string? Description,
    bool IsDefault,
    List<string>? RoleIds) : ICommand<GroupDto>;
The command is a record for immutability. It implements ICommand<GroupDto> from the Mediator library (not MediatR).

2. Command Handler

Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs
using FSH.Framework.Core.Context;
using FSH.Framework.Core.Exceptions;
using FSH.Modules.Identity.Contracts.DTOs;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;

public sealed class CreateGroupCommandHandler : ICommandHandler<CreateGroupCommand, GroupDto>
{
    private readonly IdentityDbContext _dbContext;
    private readonly ICurrentUser _currentUser;

    public CreateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser)
    {
        _dbContext = dbContext;
        _currentUser = currentUser;
    }

    public async ValueTask<GroupDto> Handle(CreateGroupCommand command, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

        // Validate name is unique within tenant
        var nameExists = await _dbContext.Groups
            .AnyAsync(g => g.Name == command.Name, cancellationToken);

        if (nameExists)
        {
            throw new CustomException(
                $"Group with name '{command.Name}' already exists.", 
                (IEnumerable<string>?)null, 
                System.Net.HttpStatusCode.Conflict);
        }

        // Validate role IDs exist
        if (command.RoleIds is { Count: > 0 })
        {
            var existingRoleIds = await _dbContext.Roles
                .Where(r => command.RoleIds.Contains(r.Id))
                .Select(r => r.Id)
                .ToListAsync(cancellationToken);

            var invalidRoleIds = command.RoleIds.Except(existingRoleIds).ToList();
            if (invalidRoleIds.Count > 0)
            {
                throw new NotFoundException(
                    $"Roles not found: {string.Join(", ", invalidRoleIds)}");
            }
        }

        // Create domain entity using factory method
        var group = Group.Create(
            name: command.Name,
            description: command.Description,
            isDefault: command.IsDefault,
            isSystemGroup: false,
            createdBy: _currentUser.GetUserId().ToString());

        // Add role assignments
        if (command.RoleIds is { Count: > 0 })
        {
            foreach (var roleId in command.RoleIds)
            {
                _dbContext.GroupRoles.Add(GroupRole.Create(group.Id, roleId));
            }
        }

        _dbContext.Groups.Add(group);
        await _dbContext.SaveChangesAsync(cancellationToken);

        // Get role names for response
        var roleNames = command.RoleIds is { Count: > 0 }
            ? await _dbContext.Roles
                .Where(r => command.RoleIds.Contains(r.Id))
                .Select(r => r.Name!)
                .ToListAsync(cancellationToken)
            : [];

        return new GroupDto
        {
            Id = group.Id,
            Name = group.Name,
            Description = group.Description,
            IsDefault = group.IsDefault,
            IsSystemGroup = group.IsSystemGroup,
            MemberCount = 0,
            RoleIds = command.RoleIds?.AsReadOnly(),
            RoleNames = roleNames.AsReadOnly(),
            CreatedAt = group.CreatedAt
        };
    }
}
Handlers implement ICommandHandler<TCommand, TResponse> and return ValueTask<TResponse> (not Task<TResponse>).

3. Validator

Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs
using FluentValidation;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;

namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;

public sealed class CreateGroupCommandValidator : AbstractValidator<CreateGroupCommand>
{
    public CreateGroupCommandValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Group name is required.")
            .MaximumLength(256).WithMessage("Group name must not exceed 256 characters.");

        RuleFor(x => x.Description)
            .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters.");
    }
}
Validators are automatically discovered and registered through AddValidatorsFromAssemblies() in the module loader.

4. Endpoint Mapping

Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupEndpoint.cs
using FSH.Framework.Shared.Identity;
using FSH.Framework.Shared.Identity.Authorization;
using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup;
using Mediator;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;

namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup;

public static class CreateGroupEndpoint
{
    public static RouteHandlerBuilder MapCreateGroupEndpoint(this IEndpointRouteBuilder endpoints)
    {
        return endpoints.MapPost("/groups", 
            (IMediator mediator, [FromBody] CreateGroupCommand request, CancellationToken cancellationToken) =>
                mediator.Send(request, cancellationToken))
        .WithName("CreateGroup")
        .WithSummary("Create a new group")
        .RequirePermission(IdentityPermissionConstants.Groups.Create)
        .WithDescription("Create a new group with optional role assignments.");
    }
}
Endpoints use Minimal APIs (no controllers). The .RequirePermission() extension enforces authorization.

5. Module Registration

The endpoint is registered in IdentityModule.MapEndpoints():
src/Modules/Identity/IdentityModule.cs
public void MapEndpoints(IEndpointRouteBuilder endpoints)
{
    var group = endpoints
        .MapGroup("api/v{version:apiVersion}/identity")
        .WithTags("Identity")
        .WithApiVersionSet(apiVersionSet);

    // Register the CreateGroup endpoint
    group.MapCreateGroupEndpoint();
    
    // ... other endpoints
}

Query Example: GetGroupById

Queries follow the same pattern but use IQuery<T> instead:
// Modules.Identity.Contracts/v1/Groups/GetGroupById/
using FSH.Modules.Identity.Contracts.DTOs;
using Mediator;

namespace FSH.Modules.Identity.Contracts.v1.Groups.GetGroupById;

public sealed record GetGroupByIdQuery(Guid Id) : IQuery<GroupDto>;

Benefits of Vertical Slices

High Cohesion

Related code lives together. No jumping between folders.

Low Coupling

Features are independent. Changes to one feature don’t affect others.

Easy Navigation

Feature name tells you exactly where to find the code.

Simple Testing

Test one feature at a time with clear boundaries.

Feature Organization

The Identity module organizes features by domain concept and version:
Modules.Identity/Features/
└── v1/
    ├── Users/
    │   ├── RegisterUser/
    │   ├── DeleteUser/
    │   ├── GetUserById/
    │   ├── GetUsers/
    │   ├── SearchUsers/
    │   ├── ChangePassword/
    │   └── AssignUserRoles/
    ├── Roles/
    │   ├── GetRoles/
    │   ├── GetRoleById/
    │   ├── UpsertRole/
    │   ├── DeleteRole/
    │   └── UpdateRolePermissions/
    ├── Groups/
    │   ├── CreateGroup/
    │   ├── UpdateGroup/
    │   ├── DeleteGroup/
    │   ├── GetGroups/
    │   ├── GetGroupById/
    │   ├── GetGroupMembers/
    │   └── AddUsersToGroup/
    ├── Tokens/
    │   ├── TokenGeneration/
    │   └── RefreshToken/
    └── Sessions/
        ├── GetMySessions/
        ├── RevokeSession/
        └── RevokeAllSessions/
The v1/ folder enables API versioning. Future breaking changes go in v2/, v3/, etc.

Naming Conventions

Feature names follow the pattern:
{Action}{Entity}
Examples:
  • CreateGroup (not AddGroup or NewGroup)
  • DeleteUser (not RemoveUser)
  • UpdateRolePermissions (not ModifyPermissions)
Use accurate verbs:
  • Create = new entity
  • Update = modify existing
  • Upsert = create or update
  • Delete = remove entity
  • Get = query single entity
  • List/Search = query multiple entities

Validation Pipeline

Validators are executed automatically via a Mediator pipeline behavior:
src/BuildingBlocks/Web/Mediator/Behaviors/ValidationBehavior.cs
public class ValidationBehavior<TMessage, TResponse> : IPipelineBehavior<TMessage, TResponse>
    where TMessage : IMessage
{
    private readonly IEnumerable<IValidator<TMessage>> _validators;

    public async ValueTask<TResponse> Handle(
        TMessage message,
        CancellationToken cancellationToken,
        MessageHandlerDelegate<TMessage, TResponse> next)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TMessage>(message);
            var validationResults = await Task.WhenAll(
                _validators.Select(v => v.ValidateAsync(context, cancellationToken)));

            var failures = validationResults
                .SelectMany(r => r.Errors)
                .Where(f => f != null)
                .ToList();

            if (failures.Count != 0)
            {
                throw new ValidationException(failures);
            }
        }

        return await next(message, cancellationToken);
    }
}
Validation happens before the handler executes. If validation fails, the handler never runs.

Complete Request Flow

1

HTTP Request

Client sends POST /api/v1/identity/groups with JSON body
2

Model Binding

ASP.NET Core binds JSON to CreateGroupCommand
3

Authorization

.RequirePermission() checks user has Groups.Create permission
4

Validation

ValidationBehavior runs CreateGroupCommandValidator
5

Handler Execution

CreateGroupCommandHandler.Handle() executes business logic
6

Response

Handler returns GroupDto which is serialized to JSON

Testing a Vertical Slice

Each feature can be tested independently:
public class CreateGroupTests
{
    [Fact]
    public async Task CreateGroup_WithValidCommand_ReturnsGroupDto()
    {
        // Arrange
        var dbContext = CreateInMemoryDbContext();
        var currentUser = CreateMockCurrentUser();
        var handler = new CreateGroupCommandHandler(dbContext, currentUser);
        var command = new CreateGroupCommand("Admins", "Admin group", false, null);

        // Act
        var result = await handler.Handle(command, CancellationToken.None);

        // Assert
        Assert.NotNull(result);
        Assert.Equal("Admins", result.Name);
    }

    [Fact]
    public async Task CreateGroup_WithDuplicateName_ThrowsCustomException()
    {
        // Arrange
        var dbContext = CreateInMemoryDbContext();
        await dbContext.Groups.AddAsync(Group.Create("Admins", null, false, false, null));
        await dbContext.SaveChangesAsync();
        
        var handler = new CreateGroupCommandHandler(dbContext, CreateMockCurrentUser());
        var command = new CreateGroupCommand("Admins", "Duplicate", false, null);

        // Act & Assert
        await Assert.ThrowsAsync<CustomException>(() => 
            handler.Handle(command, CancellationToken.None));
    }
}

Next Steps

CQRS & Mediator

Deep dive into ICommand, IQuery, and handlers

Domain-Driven Design

Learn about entities, aggregates, and domain events

Build docs developers (and LLMs) love