Skip to main content

Validating Inputs with FluentValidation

Validating Inputs with FluentValidation
Clean Architecture: Contact Management Application - This article is part of a series.
Part 3: This Article

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.

Clean Architecture: Contact Management Application - This article is part of a series.
Part 3: This Article

Related

Dependency Injection Setup Across Layers

··6 mins
Program.cs is where service registration goes to rot. It starts at five lines, then every feature adds its repositories, validators, and settings bindings until nobody can tell which layer owns what. The Contact Management Application avoids that with one rule: each layer registers its own services through an extension method, and Program.cs only calls those methods. This post walks through that setup layer by layer, using the real code, including two spots where the real code deserves a warning label.

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.

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.