In the previous article, I delved into validating data using the built-in solution - attributes from the Data Annotations namespace. But what if we want to more extensively validate fields or change validation rules during the operation of our application? The solution is the Fluent Validation library, which is under the care of the .NET Foundation.
The Fluent Interface is a concept dating back to 2005 when Eric Evans and Martin Fowler introduced it to the world during their Domain Driven Design workshops. This pattern allows a programmer to modify/configure an object using a chain of method calls.
With the emergence of the LINQ library and Entity Framework, this pattern has become well-established in the .NET platform.
When discussing the advantages of the Fluent API, it’s impossible not to mention a significant improvement in code readability by bringing it into a domain-specific cause-and-effect chain. Another widely used argument for its benefits is that the Fluent approach hides implementation details, focusing on describing business rules.
Sample Comparison of Fluent Interface with the Classic Approach:
//old approach
var order = new Order();
order.Items.Add(new Item("Item 1",1));
order.Items.Add(new Item("Item 2",1));
order.User = new User();
//Fluent
var order2 = new Order();
order2
.AddProduct("Item 1",1)
.AddProduct("Item 2",1)
.WithUser();
In contrast to the advantages, we encounter arguments that code written using the Fluent approach is challenging to debug or log the individual states of objects. Another inconvenience is the complicated implementation in strongly-typed languages because, in the case of inheritance, the inheriting class must override all Fluent methods and return its instance.
Example of an issue arising from a change in type:
class A {
public A DoMagic() { }
}
class B : A{
public B DoMagic() { super.DoMagic(); return this; } // Must change return type to B.
public B DoMoreMagic() {return this;}
}
class C : A{
public C DoMoreMagic() {return this;}
}
var b = new B();
b.DoMagic().DoMoreMagic(); //it works!
var c = new C();
c.DoMagic().DoMoreMagic(); //kaboom!
It’s worth delving into the Fluent Validation library, which is the key focus of this article. But how about describing rules using Fluent definitions? In the case of Fluent Validation, it’s trivial.
Sample code of validation rules along with the invocation for the POCO class Person
:
public class Person
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public int Age { get; set; }
public string PostCode {get;set;}
}
public class PersonValidator : AbstractValidator<Person>
{
public PersonValidator()
{
RuleFor(x => x.Id).NotNull();
RuleFor(x => x.Name).Length(0, 10);
RuleFor(x => x.Email).EmailAddress()
.WithMessage("Please ensure that you have entered your Email");
RuleFor(x => x.Age).InclusiveBetween(18, 60);
RuleFor(x => x.PostCode).Must(BePolishPostcode);
}
private bool BePolishPostcode(string postcode)
{
//...
}
}
var _validator = new PersonValidator();
var results = await _validator.ValidateAsync(person);
if(!results.IsValid)
{
foreach(var failure in results.Errors)
{
Console.WriteLine("Property " + failure.PropertyName + " failed validation. Error was: " + failure.ErrorMessage);
}
}
As you can see, we can extract our validator into a separate class. Thanks to the fact that the AbstractValidator
class implements the IValidator<T>
interface, we can also register such a validator in Dependency Injection.
Of course, the library’s capabilities are not limited to simple checking of rules for fields in the class and returning errors. We can define validators conditionally:
RuleFor(person => p.Age).GreaterThan(30).When(p => p.Name.Contains("Old"));
What if we need to define our own validator because the built-in ones do not meet our requirements? It’s enough to build an extension method returning IRuleBuilderOptions
:
//taken from docs -> https://docs.fluentvalidation.net/en/latest/custom-validators.html
public static class MyValidators {
public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {
return ruleBuilder.Must(list => list.Count < num).WithMessage("The list contains too many items");
}
}
For more functionality, please refer to the project documentation.
Using Fluent Validation in the context of the RES interface, we will implement it with a simple calculator project exposing 3 endpoints:
Let’s start by creating the project and installing the library:
dotnet new webapi -minimal
dotnet add package FluentValidation
We will define records responsible for requests to the endpoints:
public abstract record CalcRequest(double A, double B);
public record AddRequest(double A, double B): CalcRequest(A,B);
public record SubRequest(double A, double B): CalcRequest(A,B);
public record DivRequest(double A, double B): CalcRequest(A,B);
The next step is to define validators:
public class CalcRequestValidator : AbstractValidator<CalcRequest>
{
public CalcRequestValidator()
{
RuleFor(x => x.A).NotEmpty();
RuleFor(x => x.B).NotEmpty();
}
}
public class AddRequestValidator : AbstractValidator<AddRequest>
{
public AddRequestValidator()
{
Include(new CalcRequestValidator());
//my dummy rule, just for demo purposes
RuleFor(x => x.B).GreaterThan(10);
}
}
public class SubRequestValidator : AbstractValidator<SubRequest>
{
public SubRequestValidator()
{
Include(new CalcRequestValidator());
//my dummy rule, just for demo purposes
RuleFor(x => x.B).GreaterThanOrEqualTo(-10);
}
}
public class DivRequestValidator : AbstractValidator<DivRequest>
{
public DivRequestValidator()
{
Include(new CalcRequestValidator());
//my dummy rule, just for demo purposes
RuleFor(x => x.B).NotEqual(0);
}
}
Once we have set up the project foundation, it’s time to start using it.
The first step will be to register our validators in the Dependency Injection container:
builder.Services.AddScoped<IValidator<CalcRequest>, CalcRequestValidator>();
builder.Services.AddScoped<IValidator<AddRequest>, AddRequestValidator>();
builder.Services.AddScoped<IValidator<DivRequest>, DivRequestValidator>();
builder.Services.AddScoped<IValidator<SubRequest>, SubRequestValidator>();
Now we need to write the REST endpoints. In my case, I used the Minimal API concept. It’s important to inject IValidator<T>
into the controllers/methods in the minimal API:
app.MapPost("/calc/add", async ([FromServices] IValidator<AddRequest> validator, [FromBody] AddRequest req) =>
{
var valResults = await validator.ValidateAsync(req);
if (valResults.IsValid == false)
{
return Results.ValidationProblem(valResults.ToDictionary());
}
return Results.Ok(req.A + req.B);
})
.WithName("CalcAdd")
.ProducesValidationProblem(400)
.Produces(200)
.WithOpenApi();
app.MapPost("/calc/div", async ([FromServices] IValidator<DivRequest> validator, [FromBody] DivRequest req) =>
{
var valResults = await validator.ValidateAsync(req);
if (valResults.IsValid == false)
{
return Results.ValidationProblem(valResults.ToDictionary());
}
return Results.Ok(req.A / req.B);
})
.WithName("CalcDiv")
.ProducesValidationProblem(400)
.Produces(200)
.WithOpenApi();
app.MapPost("/calc/sub", async ([FromServices] IValidator<SubRequest> validator, [FromBody] SubRequest req) =>
{
var valResults = await validator.ValidateAsync(req);
if (valResults.IsValid == false)
{
return Results.ValidationProblem(valResults.ToDictionary());
}
return Results.Ok(req.A - req.B);
})
.WithName("CalcSub")
.ProducesValidationProblem(400)
.Produces(200)
.WithOpenApi();
Of course, this is just a model example to illustrate the principle. In production, it would be worthwhile to reduce repetitive code.
After running the project and making a sample request, we should receive validation results:
The entire code of the demo application is available on Github
In summary, when we expect more than attribute validation and value the ability to configure validators in code, the Fluent Validation project is an ideal solution to our problems.
However, the decision on the validation solution is made, as usual, based on the application and project requirements. Sometimes the Data Annotations approach may be fully sufficient for our project.