# Validation in Vertical Slice Architecture

> Where does validation go in Vertical Slice Architecture? Co-locate validators with features and use a pipeline behavior to run them automatically.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/validation-vertical-slice-architecture

Validation in Vertical Slice Architecture lives in the slice: the validator sits right next to the command and handler it guards, and a pipeline behavior runs it automatically.
In a layered codebase, validation rules end up far from the feature they protect.
Here is the full setup with FluentValidation and MediatR: co-located validators, automatic execution, and a clean split between input validation and domain validation.

## Where Does Validation Go?

In [**Vertical Slice Architecture**](https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet), each feature is self-contained. The validator lives next to the handler - not in a separate "Validators" folder across the project.

```
Features/
  Orders/
    PlaceOrder.cs        ← Command + Handler + Validator
    GetOrder.cs
    CancelOrder.cs
```

## The Validator

Use FluentValidation to define rules:

```csharp
public static class PlaceOrder
{
    public sealed record Command(
        Guid CustomerId,
        List<OrderItemRequest> Items) : IRequest<Result<Guid>>;

    public sealed record OrderItemRequest(
        Guid ProductId,
        int Quantity,
        decimal UnitPrice);

    public sealed class Validator : AbstractValidator<Command>
    {
        public Validator()
        {
            RuleFor(x => x.CustomerId)
                .NotEmpty()
                .WithMessage("Customer ID is required.");

            RuleFor(x => x.Items)
                .NotEmpty()
                .WithMessage("At least one item is required.");

            RuleForEach(x => x.Items).ChildRules(item =>
            {
                item.RuleFor(x => x.ProductId).NotEmpty();
                item.RuleFor(x => x.Quantity)
                    .GreaterThan(0)
                    .WithMessage("Quantity must be positive.");
                item.RuleFor(x => x.UnitPrice)
                    .GreaterThan(0)
                    .WithMessage("Price must be positive.");
            });
        }
    }

    public sealed class Handler : IRequestHandler<Command, Result<Guid>>
    {
        private readonly ApplicationDbContext _db;

        public Handler(ApplicationDbContext db) => _db = db;

        public async Task<Result<Guid>> Handle(
            Command request, CancellationToken ct)
        {
            // No input validation here - the pipeline already ran it
            var order = Order.Create(request.CustomerId, request.Items);

            _db.Orders.Add(order);
            await _db.SaveChangesAsync(ct);

            return order.Id;
        }
    }
}
```

The command, validator, and handler are all in one file. Everything about "Place Order" is in one place.

## Automatic Validation With a Pipeline Behavior

Instead of calling the validator manually in every handler, use a MediatR pipeline behavior. This is the same approach I showed for [**CQRS validation with MediatR and FluentValidation**](https://milanjovanovic.tech/blog/cqrs-validation-with-mediatr-pipeline-and-fluentvalidation):

```csharp
public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

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

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

        return await next();
    }
}
```

Register it:

```csharp
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

builder.Services.AddValidatorsFromAssembly(
    typeof(Program).Assembly);
```

Every request that has a matching `IValidator<T>` is validated automatically before reaching the handler.
Pair this throwing variant with a [**global exception handler**](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers) that turns `ValidationException` into a 400 Problem Details response.

## Result-Based Validation

Instead of throwing exceptions, return validation errors as a `Result`:

```csharp
public sealed class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
    where TResponse : Result
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var validationResults = await Task.WhenAll(
            _validators.Select(v => v.ValidateAsync(context, ct)));

        var errors = validationResults
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .Select(f => new Error(f.PropertyName, f.ErrorMessage))
            .ToArray();

        if (errors.Length != 0)
            return (TResponse)(object)Result.Failure(
                new ValidationError(errors));

        return await next();
    }
}
```

The handler never runs if validation fails. The endpoint returns a 400 response with the validation errors.

One wrinkle to be aware of: the cast at the end only works when `TResponse` is the non-generic `Result`. For handlers returning `Result<T>`, a complete implementation creates the typed failure through a static factory or a small piece of reflection. It's a one-time cost in the behavior, and every slice benefits.

## Input Validation vs Domain Validation

There are two layers of validation in any application:

![Input validation with FluentValidation runs before the handler and rejects bad format; domain validation runs inside the handler and enforces business rules like stock and customer status](https://milanjovanovic.tech/blogs/articles/validation-vertical-slice-architecture/input-vs-domain-validation.png)

**Input validation** (FluentValidation) - checks data format and presence:
- Is the email format valid?
- Is the quantity positive?
- Is the required field present?

**Domain validation** (**domain invariants**) - checks business rules:
- Can this customer place an order?
- Is this product in stock?
- Does the discount code apply?

```csharp
// Input validation (FluentValidation) - runs BEFORE the handler
public sealed class Validator : AbstractValidator<Command>
{
    public Validator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleForEach(x => x.Items).ChildRules(item =>
        {
            item.RuleFor(x => x.ProductId).NotEmpty();
            item.RuleFor(x => x.Quantity).GreaterThan(0);
        });
    }
}

// Domain validation - runs INSIDE the handler
public sealed class Handler : IRequestHandler<Command, Result<Guid>>
{
    private readonly ApplicationDbContext _db;

    public Handler(ApplicationDbContext db) => _db = db;

    public async Task<Result<Guid>> Handle(
        Command request, CancellationToken ct)
    {
        var productIds = request.Items.Select(i => i.ProductId).ToList();

        var products = await _db.Products
            .Where(p => productIds.Contains(p.Id))
            .ToDictionaryAsync(p => p.Id, ct);

        foreach (var item in request.Items)
        {
            if (!products.TryGetValue(item.ProductId, out var product))
                return Result.Failure<Guid>(ProductErrors.NotFound);

            if (product.StockQuantity < item.Quantity)
                return Result.Failure<Guid>(ProductErrors.InsufficientStock);
        }

        // Domain checks passed - create the order
        var order = Order.Create(request.CustomerId, request.Items);

        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);

        return order.Id;
    }
}
```

Input validation rejects obviously bad data. Domain validation enforces business rules.

## Async Validators

Some validation requires database access:

```csharp
public sealed class Validator : AbstractValidator<Command>
{
    public Validator(ApplicationDbContext db)
    {
        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, ct) =>
                !await db.Users.AnyAsync(u => u.Email == email, ct))
            .WithMessage("Email is already registered.");
    }
}
```

Use async validators sparingly. Most input validation should be synchronous. Save database checks for the handler when possible.

## Testing Your Validators

Co-located validators are trivially testable with FluentValidation's built-in `TestHelper`:

```csharp
public class PlaceOrderValidatorTests
{
    private readonly PlaceOrder.Validator _validator = new();

    [Fact]
    public void Should_Fail_When_Items_Are_Empty()
    {
        var command = new PlaceOrder.Command(Guid.NewGuid(), []);

        var result = _validator.TestValidate(command);

        result.ShouldHaveValidationErrorFor(x => x.Items);
    }

    [Fact]
    public void Should_Pass_For_Valid_Command()
    {
        var command = new PlaceOrder.Command(
            Guid.NewGuid(),
            [new PlaceOrder.OrderItemRequest(Guid.NewGuid(), 2, 10m)]);

        var result = _validator.TestValidate(command);

        result.ShouldNotHaveAnyValidationErrors();
    }
}
```

`TestValidate` gives you assertion helpers that point at the exact rule that failed. These tests run in microseconds, so cover every rule. More on the broader strategy in [**testing vertical slices**](https://milanjovanovic.tech/blog/testing-vertical-slices-dotnet).

## Endpoint Error Mapping

Map validation errors to Problem Details:

```csharp
app.MapPost("/api/orders", async (
    PlaceOrder.Command command,
    ISender sender) =>
{
    var result = await sender.Send(command);

    return result.Match(
        onSuccess: id => Results.Created($"/api/orders/{id}", id),
        onFailure: error => error switch
        {
            ValidationError ve => Results.ValidationProblem(
                ve.Errors.GroupBy(e => e.Code)
                    .ToDictionary(
                        g => g.Key,
                        g => g.Select(e => e.Description).ToArray())),
            _ => Results.Problem(
                detail: error.Description,
                statusCode: StatusCodes.Status400BadRequest)
        });
});
```

## Summary

Validation in Vertical Slice Architecture:

1. **Co-locate validators with features** - same file as the command and handler
2. **Pipeline behavior** validates automatically before the handler runs
3. **Input validation** (format, presence) goes in FluentValidation
4. **Domain validation** (business rules) stays in the handler or domain model
5. **Throw or return Results** - both work, pick one convention and stay consistent
6. **Map to Problem Details** for consistent API error responses

Validation is a [**cross-cutting concern**](https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-slice-architecture) - handle it once in the pipeline, not in every handler.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### Where does validation go in Vertical Slice Architecture?

In the slice itself. The FluentValidation validator lives in the same file (or folder) as the command and handler, and a MediatR pipeline behavior runs it automatically before the handler executes.

### What is the difference between input validation and domain validation?

Input validation checks format and presence (email shape, positive quantity) and runs before the handler. Domain validation enforces business rules that need state, like stock levels or customer status, and belongs in the handler or the domain model.

### Should validation throw exceptions or return results?

Both work. Throwing a ValidationException with a global exception handler is simpler to wire up. Returning a Result keeps validation failures out of exception flow and makes them explicit in the handler signature. Pick one and be consistent.

### Can FluentValidation validators access the database?

Yes, via MustAsync with an injected DbContext, but use it sparingly. Uniqueness and stock checks are usually better handled in the handler where you control transactions and error types.
