Skip to main content

MediatR Integration

The Vali-Validation.MediatR package registers an IPipelineBehavior<TRequest, TResponse> that automatically validates the request before it reaches the handler. If validation fails, it throws ValidationException.

Install

dotnet add package Vali-Validation.MediatR
dotnet add package MediatR

The Vali-Validation.MediatR package already depends on Vali-Validation (core), so you do not need to install it separately in the API project.


Pipeline Flow

loading...

How the Pipeline Works

The behavior does the following:

  1. Attempts to resolve IValidator<TRequest> from the DI container.
  2. If no validator is registered, the request passes directly to the handler (no-op).
  3. If there is a validator, executes ValidateAsync(request).
  4. If !result.IsValid, throws ValidationException with all errors.
  5. If valid, calls next() and the handler executes.

Registration Methods

Option 1: AddValiValidationBehavior (behavior only)

// Only registers the behavior — you register MediatR and validators separately
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
builder.Services.AddValidationsFromAssembly(assembly);
builder.Services.AddValiValidationBehavior(); // Adds the behavior to the pipeline

Option 2: AddMediatRWithValidation (all-in-one)

// Registers MediatR, the behavior and validators from the assembly in a single call
builder.Services.AddMediatRWithValidation(
cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly),
validatorsAssembly: typeof(Program).Assembly,
lifetime: ServiceLifetime.Transient);

If validators are in a different assembly from the handlers:

builder.Services.AddMediatRWithValidation(
cfg => cfg.RegisterServicesFromAssembly(typeof(ApplicationAssembly).Assembly),
validatorsAssembly: typeof(ApplicationAssembly).Assembly,
lifetime: ServiceLifetime.Scoped); // Scoped if validators use DbContext

Complete Example

Model and Command

// Application/Commands/CreateProduct/CreateProductCommand.cs
public record CreateProductCommand : IRequest<ProductDto>
{
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public decimal Price { get; init; }
public int Stock { get; init; }
public string Category { get; init; } = string.Empty;
}

public record ProductDto(int Id, string Name, decimal Price, string Category);

Validator

// Application/Commands/CreateProduct/CreateProductCommandValidator.cs
using Vali_Validation.Core.Validators;

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
private static readonly string[] ValidCategories =
new[] { "Electronics", "Clothing", "Books", "Food", "Other" };

public CreateProductCommandValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.WithMessage("The product name is required.")
.WithErrorCode("NAME_REQUIRED")
.MinimumLength(3)
.WithMessage("The name must have at least 3 characters.")
.MaximumLength(200)
.WithMessage("The name cannot exceed 200 characters.");

RuleFor(x => x.Description)
.MaximumLength(1000)
.WithMessage("The description cannot exceed 1000 characters.");

RuleFor(x => x.Price)
.GreaterThan(0m)
.WithMessage("The price must be greater than 0.")
.WithErrorCode("PRICE_INVALID")
.MaxDecimalPlaces(2)
.WithMessage("The price cannot have more than 2 decimal places.");

RuleFor(x => x.Stock)
.GreaterThanOrEqualTo(0)
.WithMessage("The stock cannot be negative.");

RuleFor(x => x.Category)
.NotEmpty()
.WithMessage("The category is required.")
.In(ValidCategories)
.WithMessage($"The category must be one of: {string.Join(", ", ValidCategories)}.");
}
}

Handler

// Application/Commands/CreateProduct/CreateProductHandler.cs
public class CreateProductHandler : IRequestHandler<CreateProductCommand, ProductDto>
{
private readonly IProductRepository _repository;
private readonly ILogger<CreateProductHandler> _logger;

public CreateProductHandler(IProductRepository repository, ILogger<CreateProductHandler> logger)
{
_repository = repository;
_logger = logger;
}

public async Task<ProductDto> Handle(
CreateProductCommand command,
CancellationToken cancellationToken)
{
// If we get here, validation has already passed in the behavior
// No need to validate manually
_logger.LogInformation("Creating product: {Name}", command.Name);

var product = new Product
{
Name = command.Name,
Description = command.Description,
Price = command.Price,
Stock = command.Stock,
Category = command.Category
};

var created = await _repository.CreateAsync(product, cancellationToken);

return new ProductDto(created.Id, created.Name, created.Price, created.Category);
}
}

Controller

// Api/Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;

public ProductsController(IMediator mediator)
{
_mediator = mediator;
}

[HttpPost]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(
[FromBody] CreateProductCommand command,
CancellationToken ct)
{
// The behavior validates before reaching the handler.
// If validation fails → ValidationException → middleware returns 400.
// If validation passes → handler returns ProductDto.
var product = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var product = await _mediator.Send(new GetProductByIdQuery(id), ct);
return product is null ? NotFound() : Ok(product);
}
}

Program.cs

using Vali_Validation.Core.Extensions;

var builder = WebApplication.CreateBuilder(args);

// Infrastructure
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

// MediatR + Validation (all-in-one)
builder.Services.AddMediatRWithValidation(
cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly),
validatorsAssembly: typeof(Program).Assembly,
lifetime: ServiceLifetime.Transient);

// ASP.NET Core
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();
app.UseValiValidationExceptionHandler(); // Catches ValidationException from the behavior
app.MapControllers();

app.Run();

Behavior When There Is No Validator

If you send a command or query with no registered validator, the behavior lets it pass without error:

// This query has no validator
public record GetAllProductsQuery : IRequest<List<ProductDto>>;

// The behavior does nothing, the handler executes directly
var products = await _mediator.Send(new GetAllProductsQuery());

This is intentional: it is not required to have a validator for every request. Only requests that need one.


CancellationToken in the Behavior

The behavior passes the pipeline's CancellationToken to ValidateAsync, so if the client cancels the request, the async validation is also cancelled:

// In the validator, the behavior's CT arrives here:
RuleFor(x => x.Email)
.MustAsync(async (email, ct) =>
{
// If the client cancels, ct is cancelled and the query throws OperationCanceledException
return !await _users.EmailExistsAsync(email, ct);
});

Complete Error Flow

POST /api/products
Body: { "name": "", "price": -5, "category": "Unknown" }

→ MediatR pipeline
→ ValidationBehavior<CreateProductCommand, ProductDto>
→ ValidateAsync(command)
→ result.IsValid = false
→ throw new ValidationException(result)
→ UseValiValidationExceptionHandler middleware
→ Response 400:
{
"type": "https://tools.ietf.org/html/rfc7807",
"title": "Validation Failed",
"status": 400,
"errors": {
"Name": ["The product name is required."],
"Price": ["The price must be greater than 0."],
"Category": ["The category must be one of: Electronics, Clothing, Books, Food, Other."]
}
}

Testing the Validator with MediatR

public class CreateProductCommandValidatorTests
{
private readonly CreateProductCommandValidator _validator = new();

[Fact]
public async Task EmptyName_ShouldFail()
{
var command = new CreateProductCommand { Name = "", Price = 10, Category = "Books" };
var result = await _validator.ValidateAsync(command);

Assert.False(result.IsValid);
Assert.True(result.HasErrorFor("Name"));
}

[Fact]
public async Task ValidCommand_ShouldPass()
{
var command = new CreateProductCommand
{
Name = "Clean Code",
Price = 29.99m,
Stock = 100,
Category = "Books"
};

var result = await _validator.ValidateAsync(command);
Assert.True(result.IsValid);
}
}

Next Steps

  • Vali-Mediator — Alternative with Result<T> instead of exceptions
  • ASP.NET Core — Middleware that catches the behavior's exception