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.
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.AspNetCoreOnce 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.
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
InvalidModelStateResponseFactoryonce 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.
AddFluentValidationis 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.
