Validate your Records, Settings, DTOs and whatever else you can think with Fluent Validation.
Validation. Nobody's favorite chore, right up there with writing unit tests for code you already personally guarantee works. And yet it's one of the most important things standing between “clean data” and “why does production have a product named null with a price of negative four dollars, and why has it sold six units.”
This post is about Fluent Validation, and why it makes this particular chore almost enjoyable — a phrase I do not use lightly about anything involving the word “validation.” But first, a quick word on the type we'll be validating.
Records
If you've never used C# records, start now, today, this afternoon. Easy declaration, built-in equality comparison, cloning, sensible ToString() formatting — all the boilerplate you used to hand-roll for a plain class, gone, like it was never your problem to begin with.
Fluent Validation
Fluent Validation decouples validation logic from the object being validated — no cramming [Required] attributes onto every property like decorating a Christmas tree and hoping nobody ever needs a rule more complicated than “is this null.”
We'll wire it into a normal ASP.NET Core Web API. Full source is on GitHub: FluentValidationDemo — and yes, I actually pulled it down and ran it while writing this, because “trust me, it works” is not a sentence that should appear in a technical blog post, even one with this many exclamation points.
The solution structure:
src/
├── Business/
│ └── ProductLogic.cs
├── Controllers/
│ └── ProductsController.cs
├── Records/
│ ├── APIError.cs
│ └── Product.cs
├── Validators/
│ └── ProductValidator.cs
├── Program.cs
├── appsettings.json
└── appsettings.Development.jsonIt's a demo, so everything lives in one project — in the real world you'd split this up, but the point here is the validation pattern, not winning an architecture award.
Meet the main character, Product — pulling double duty as both DTO and entity for demo purposes. In a real codebase, keep those separate; future you does not want to debug why your API contract and your database schema are secretly the same overworked class:
public record Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
}Nothing dramatic — a plain POCO, which I prefer over anything fancier, since POCOs tend to stay blissfully unaware of whatever framework opinion is trending this year.
Now the validator, doing all the actual work while Product just sits there looking pretty:
public class ProductValidator : AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(p => p.Name).NotEmpty();
RuleFor(p => p.Description).NotEmpty();
RuleFor(p => p.Price).NotNull().PrecisionScale(4, 2, false).GreaterThan(0);
}
}Name and Description just need to not be empty — a bar so low it's practically underground, and yet you'd be amazed. Price needs to actually exist, fit within 4 digits total with 2 decimal places, and be greater than zero, because a product that costs nothing — or costs a negative amount — is either an extremely generous business model or a bug wearing a business model as a disguise. Fluent Validation ships plenty of rules like these out of the box, and you can bolt on your own once the built-ins stop being enough.
I'll skip over ProductLogic — it's a mockup returning a hardcoded list, standing in for wherever your real business logic and Entity Framework calls would eventually go do their actual jobs.
Wiring it all up in Program.cs:
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IValidator<Product>, ProductValidator>();
builder.Services.AddScoped<ProductLogic>();Both the validator and the business logic register as scoped services — the correct lifetime for a stateless API, since nobody wants last request's emotional baggage carrying over into this one. (Swagger's tucked in there too — it'll get the full spotlight, OpenAPI standards and how beautifully it plays with API management gateways, in a future post. Yes, another future post. I have several. It's a whole backlog now.)
And finally, the controller, where the validator actually earns its keep instead of just existing decoratively:
[HttpPost(Name = "PostProduct")]
public IActionResult Post([FromBody] Product product)
{
var validationResult = _validator.Validate(product);
if (validationResult is not null && !validationResult.IsValid)
return new JsonResult(new APIError
{
Entity = JsonSerializer.Serialize(product),
Errors = (from err in validationResult.Errors
select err.ErrorMessage).ToList()
})
{
StatusCode = StatusCodes.Status400BadRequest
};
return new JsonResult(_productLogic.CreateProduct(product));
}Notice we're not just serializing FluentValidation's own ValidationResult straight back to the caller — that would leak an internal library's shape directly into your API contract, which is exactly the kind of tight coupling this entire post exists to argue against. Instead, a small APIError record hands back the original submitted entity plus a clean list of human-readable error strings — something stable and yours, not “whatever FluentValidation happens to feel like serializing this month.”
(Remind me to actually write the OData post — we'll reuse this exact codebase and give it a glow-up. If you're impatient, the basics are here.)
Two endpoints exist: get products, create product. Riveting stuff. Here's GET /Products against the actual running demo, not a mockup of a mockup:
$ curl http://localhost:5099/Products
[{"id":1,"name":"Product 01","description":"Description Product 01","price":10},{"id":2,"name":"Product 02","description":"Description Product 02","price":15},{"id":3,"name":"Product 03","description":"Description Product 03","price":20},{"id":4,"name":"Product 04","description":"Description Product 04","price":25},{"id":5,"name":"Product 05","description":"Description Product 05","price":30}]A valid POST, playing by the rules:
$ curl -X POST http://localhost:5099/Products -H "Content-Type: application/json" -d '{"id":1,"name":"Widget","description":"A very fine widget","price":19.99}'
{"id":1,"name":"Widget","description":"A very fine widget","price":19.99}And now, the fun part — the entire reason this post exists — send something the validator actually has opinions about:
$ curl -X POST http://localhost:5099/Products -H "Content-Type: application/json" -d '{"id":2,"name":"","description":"","price":-5}'
{"entity":"{\"Id\":2,\"Name\":\"\",\"Description\":\"\",\"Price\":-5}","errors":["'Name' must not be empty.","'Description' must not be empty.","'Price' must be greater than '0'."]}Three rules, three violations, three plain-English error messages, and the original payload handed right back so the consumer knows exactly what they sent and exactly what to fix. No exceptions, no mysterious 500s, no stack trace as a personality — just a clean 400 and a straight answer. Which, when you think about it, is really all validation was ever supposed to do; it just usually forgets.
This is a small example, but the pattern scales well past one lonely controller. Personally, I reach for Fluent Validation for:
- Reading settings and reporting back exactly which ones are wrong, instead of a generic “something, somewhere, is broken”
- Validating a POCO on the consumer side before it ever reaches the API, so the API isn't playing bouncer for problems that should've been caught upstream
- DTO and record validation, same idea, different layer
- Business rules with more nuance than “is this field empty,” because reality is rarely that polite
- Localizing error messages, since the library supports that natively and nobody wants an error message only half the company can read
Happy coding!!!
Al considerar cómo elegir camisetas de fútbol retro para una colección, la decisión resulta más sencilla cuando se confirma el escudo, el cuello y la disposición de los patrocinadores. Para evitar errores, merece la pena revisar las instrucciones de conservación para estampados y bordados.