Skip to main content

Validating Inputs with FluentValidation

Validating Inputs with FluentValidation
Table of Contents

Introduction
#

Validation is the one part of a request I refuse to trust anywhere but the edge. Domain objects assume they are already valid; rejecting a malformed email or a missing name belongs at the API boundary, before that data reaches a service or a repository.

On the Contact Management Application I use FluentValidation for exactly that: declarative rules on the DTOs, wired into the ASP.NET Core pipeline and kept out of the controllers and the domain. This post covers where those validators live, how to write them, and how a validation failure turns into a clean 400 response.

What you’ll learn
#

  • The role FluentValidation plays in a Clean Architecture, and why validation sits in the API layer.
  • How to register FluentValidation in an ASP.NET Core project.
  • How to define validation rules for create and update DTOs.
  • How to handle validation failures and shape the error response.
Part of a series building the Contact Management Application, a sample project that walks through Clean Architecture in .NET end to end. This post covers the input-validation layer.

1. The role of FluentValidation in Clean Architecture
#

Every web application has to reject data that fails its rules, and where that rejection happens matters. In a Clean Architecture you want validation decoupled from business rules and domain logic. FluentValidation lets you define validation rules for DTOs in a declarative style and keeps the API layer readable by pulling that logic out of the controllers and services.

Some advantages of using FluentValidation:

  • Separation of concerns: Validation logic stays independent of business logic, which keeps the structure clear.

  • Readability: Fluent API makes it easy to read and understand validation rules.

  • Reusability: You can reuse validation logic across different layers or projects.

  • Customizable error messages: Provides user-friendly error messages when validation fails.


2. Setting Up FluentValidation
#

To start using FluentValidation in your ASP.NET Core project, you first need to install the FluentValidation.AspNetCore package:

Install-Package FluentValidation.AspNetCore

Once installed, you can register FluentValidation in your Startup.cs file by modifying the ConfigureServices method:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    
    // Register FluentValidation services
    services.AddFluentValidation(fv => 
        fv.RegisterValidatorsFromAssemblyContaining<Startup>());
}

FluentValidation then scans the assembly for validator classes and applies them automatically.

Version note: AddFluentValidation(...) is the pre-11 registration call and is deprecated on FluentValidation 11+. New projects should use AddValidatorsFromAssemblyContaining<T>() together with AddFluentValidationAutoValidation() instead.

3. Creating and Defining Validation Rules
#

In FluentValidation, validation rules live in dedicated validator classes. Each validator checks a DTO (Data Transfer Object) so that bad data is rejected before it reaches the Application Layer. For the Contact Management Application, let’s create a validator for the CreateContactPersonDto.

3.1 Defining Validation Rules for DTOs
#

The following code defines validation rules for the CreateContactPersonDto: required fields must not be empty, and the email must be a valid format.

public class CreateContactPersonDto
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public long Mobile { get; set; }
}

Next, we create a validator for this DTO:

public class CreateContactPersonValidator : AbstractValidator<CreateContactPersonDto>
{
    public CreateContactPersonValidator()
    {
        RuleFor(x => x.FirstName)
            .NotEmpty().WithMessage("First name is required.")
            .Length(2, 50).WithMessage("First name must be between 2 and 50 characters.");

        RuleFor(x => x.LastName)
            .NotEmpty().WithMessage("Last name is required.")
            .Length(2, 50).WithMessage("Last name must be between 2 and 50 characters.");

        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required.")
            .EmailAddress().WithMessage("Invalid email format.");

        RuleFor(x => x.Mobile)
            .NotEmpty().WithMessage("Mobile number is required.")
            .GreaterThan(999999999).WithMessage("Mobile number should have at least 10 digits.");
    }
}

In this validator:

  • The FirstName and LastName fields are mandatory, with a required length ranging from 2 to 50 characters.

  • The Email field is mandatory and must adhere to a valid email format.

  • Mobile must have at least 10 digits.

3.2 Defining Validation for Update Operations
#

Similarly, we can define a validator for the UpdateContactPersonDto, which may have different validation rules since not all fields might be required during updates:

public class UpdateContactPersonDto
{
    public string Email { get; set; }
    public long Mobile { get; set; }
}
public class UpdateContactPersonValidator : AbstractValidator<UpdateContactPersonDto>
{
    public UpdateContactPersonValidator()
    {
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email is required.")
            .EmailAddress().WithMessage("Invalid email format.");
        
        RuleFor(x => x.Mobile)
            .GreaterThan(999999999).WithMessage("Mobile number should have at least 10 digits.");
    }
}

In this validator:

  • Email is required and must be in a valid format.
  • Mobile must have at least 10 digits.

4. Handling Validation in Controllers
#

Once validators are in place, FluentValidation automatically validates incoming requests. If validation fails, it returns a 400 Bad Request response with details about the validation errors.

Here’s an example of how the ContactPersonController uses FluentValidation for creating a new contact:

[ApiController]
[Route("api/[controller]")]
public class ContactPersonController : ControllerBase
{
    private readonly IContactPersonService _contactPersonService;

    public ContactPersonController(IContactPersonService contactPersonService)
    {
        _contactPersonService = contactPersonService;
    }

    [HttpPost]
    public async Task<IActionResult> Add(CreateContactPersonDto contactDto)
    {
        var createdContactPerson = await _contactPersonService.Add(contactDto);
        return CreatedAtAction(nameof(GetById), new { id = createdContactPerson.Id }, createdContactPerson);
    }
}

If the incoming request does not meet the validation rules, the controller will automatically return a response like:

{
  "errors": {
    "FirstName": [
      "First name is required."
    ],
    "Email": [
      "Invalid email format."
    ]
  }
}

4.1 Customizing Validation Failure Responses
#

You can also customize the structure of the validation failure response if you prefer a specific format. Here’s an example of how you can override the default behavior in Startup.cs:

services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = context =>
    {
        var errors = context.ModelState
            .Where(e => e.Value.Errors.Count > 0)
            .SelectMany(x => x.Value.Errors)
            .Select(x => x.ErrorMessage)
            .ToArray();

        var errorResponse = new
        {
            Status = 400,
            Errors = errors
        };

        return new BadRequestObjectResult(errorResponse);
    };
});

Now validation errors come back in one consistent shape, whatever the endpoint.


The bottom line
#

Keeping validators on the DTOs, out of the domain, buys you a few concrete things: the rules live in one place per model, the fluent API stays readable as the rules grow, and the same validator serves every controller that accepts that DTO. The framework does the rest, rejecting bad requests with a 400 before your service code ever runs.

  • Validate at the boundary. DTO validators run in the pipeline, so business logic only ever sees data that already passed.
  • One shape for errors. Override InvalidModelStateResponseFactory once and every endpoint returns failures the same way.
  • Reuse over duplication. A single validator covers create and update paths; a second validator handles the narrower update DTO.
  • Mind the registration API. AddFluentValidation is legacy; on version 11+ use the auto-validation package split shown above.

Next, wire these validators and services together. See Dependency Injection setup across layers. The full project is on GitHub: Contact Management Application.

Related

Error Handling and Exception Management in the API

··6 mins
An unhandled exception in a default ASP.NET Core API gives the client an empty 500 and gives you a stack trace in a log nobody is watching. The only thing worse is the API that returns the stack trace to the caller. The Contact Management Application takes the middle path: one middleware catches everything, maps known exception types to sensible status codes, returns a predictable JSON shape, and logs the details server-side. This post walks through that implementation and where I would lean on the newer built-in alternatives instead.

Clean Architecture: Introduction to the Project Structure

··9 mins
Most Clean Architecture articles stop at the circle diagram. The part that actually decides whether a team can live with the pattern is more mundane: which project does this class go in, which direction do the references point, and where do the interfaces live. I built the Contact Management Application as a .NET Core Web API to answer exactly those questions with running code, and this series walks through it layer by layer.

Implementing AutoMapper for DTO Mapping with Audit Details

··6 mins
Early versions of the Contact Management Application had the same four lines at the end of every create and update method: set CreatedBy, set CreatedOn (or their Updated* twins), then save. Copy-pasted audit code gets forgotten just often enough to make the audit columns untrustworthy, and an audit column you can’t trust is worse than none. Moving that logic into AutoMapper profiles fixed it in one place.