Validation in a C# class is an area where configuration / customization pressures can sneak into an application. Simple scenarios are typically easily-served with some built-in features, but as complexity builds, you may be tempted to start building some hard-coded if-then blocks that contribute to technical debt. In this article, I'll look at one technique to combat that hard-coding.
Simple Validation
Consider an application tracking cars on a dealer's lot. For this example, the car model is super-simple -- just make, model, and VIN:
public class Vehicle
{
public string Make { get; set; }
public string Model { get; set; }
public string VIN { get; set; }
}
Microsoft has a data annotation library that can handle very simple validations just by annotating properties, like this:
[Required]
public string Make { get; set; }
When used with the data annotations validation context, this is enough to tell us a model isn't considered valid:
var v = new Vehicle.Vehicle();
Assert.IsNotNull(v);
ValidationContext context = new ValidationContext(v);
List<ValidationResult> validationResults = new List<ValidationResult>();
bool valid = Validator.TryValidateObject(v, context, validationResults, true);
Assert.IsFalse(valid);
This is not, however, especially flexible. Data annotation-based validations become more difficult under circumstances like:
- Multiple values / properties participate in a single rule.
- Sometimes a validation is considered a problem, and sometimes it's not.
- You want to customize the validation message (beyond simple resx-based resources).
Introducing FluentValidation
Switching this to use FluentValidation, we pick up a class to do validation now, and the syntax for executing the validation itself isn't much different so far.
public class VehicleValidator:AbstractValidator<Vehicle>
{
public VehicleValidator()
{
RuleFor(vehicle => vehicle.Make).NotNull();
}
}
...
// testing
var v = new Vehicle.Vehicle();
var validator = new VehicleValidator();
var result = validator.Validate(v);
By itself, this change doesn't make much difference, but because we've switched to FluentValidation, a few new use cases are a bit easier. Forewarned - I'm using some contrived examples here that are super-simplified for clarity.
Contrived Example 1 - search model
Again, this example isn't what you'd use at scale, but let's say you'd like to use the Vehicle mode under two different scenarios with different sets of validation rules. When adding a new vehicle to the lot, for instance, you'd want to be sure you're filling in fields required to properly identify a real car -- make, model, vin (and presumably a boatload of other fields, too). This looks similar to the example above where we required only Make. The same model could be used as a request in a search method, but validation would be very different - in this case, we don't expect all the fields to be present, but we very well might want to ask for at least one of the fields to be present.
public SearchVehicleValidator()
{
RuleFor(v => v.Make).NotNull()
.When(v => string.IsNullOrEmpty(v.Model) && string.IsNullOrEmpty(v.VIN))
.WithMessage("At least one search criteria is required(1)");
RuleFor(v => v.Model).NotNull()
.When(v => v.Model != null)
.WithMessage("At least one search criteria is required(2)");
RuleFor(v => v.VIN).NotNull()
.When(v => v.Make != null && v.Model != null)
.WithMessage("At least one search criteria is required(3)");
}
}
In FluentValidate, this syntax isn't too bad with just three properties, but as your model increases in size, you'd want a more elegant solution. In any event, we can now validate the same class with different validation rules, depending on how we intend to use it:
var v = new Vehicle.Vehicle() { Make = "Yugo"};
var svalidator = new SearchVehicleValidator();
var result = svalidator.Validate(v);
Assert.IsTrue(result.IsValid); // one non-null field here is sufficient to be valid
var lvalidator = new ListingVehicleValidator();
result = lvalidator.Validate(v);
Assert.IsFalse(result.IsValid); // all fields must now be valid
Contrived Example #2:
Buckle up - we're going to shift into high gear on the contrived scale. For this example, let's say you're deploying this solution to several customers, and they have different rules about handling car purchases -- some expect to see a customer pre-qualified for credit prior to finalizing a purchase price, and some won't. Again, in this case, the example isn't as important as the technique, so please withhold judgement on the former. In addition to extending the Vehicle model to become a VehicleQuote model:
public class VehicleQuote: Vehicle
{
public decimal? PurchasePrice { get; set; }
public bool? CreditApproved { get; set; }
}
We're also going to validate slightly differently. Here, note the context we're passing in and the dynamic severity for the credit approved rule:
public class PurchaseVehicleValidator : AbstractValidator<VehicleQuote>
{
VehicleContext cx;
public PurchaseVehicleValidator(VehicleContext cx)
{
this.cx = cx;
RuleFor(vehicle => vehicle.Make).NotNull();
RuleFor(vehicle => vehicle.Model).NotNull();
RuleFor(vehicle => vehicle.VIN).NotNull();
// dynamic rules
RuleFor(vehicle => vehicle.CreditApproved).Must(c => c == true).WithSeverity(cx.CreditApprovedSeverity);
}
}
This lets us pass in an indicator for whether we consider credit approved to be an error, warning, or even an info message. The tests evaluate "valid" differently here, as any message hit at all -- even one with an Info severity -- will evaluate IsValid as false.
var vq = new Vehicle.VehicleQuote() { Make = "Yugo", Model = "Yugo", VIN = "some vin" };
var cx = new VehicleContext() { CreditApprovedSeverity = FluentValidation.Severity.Error };
var qvalidator = new QuoteVehicleValidator(cx);
var result = qvalidator.Validate(vq);
Assert.IsTrue(result.Errors.Any(e => e.Severity == FluentValidation.Severity.Error)); // non-approved is an error here.
cx.CreditApprovedSeverity = FluentValidation.Severity.Info;
qvalidator = new QuoteVehicleValidator(cx);
result = qvalidator.Validate(vq);
Assert.IsFalse(result.Errors.Any(e => e.Severity == FluentValidation.Severity.Error)); // all fields must now be valid
Note that the same technique can be used for validation messages -- you could drive these from a ResX file or make them entirely dyanmic if you need to create different experiences under different circumstances.
Wrapping it up
The examples here are clearly light in depth, but they should show some of the ways FluentValidation can create dynamic behavior in validation without incurring the technical debt of custom code or a labrynth of if-then's. These techniques are simple and easy to test, as well.
You can find the code for this article at https://github.com/dlambert-personal/dynamic-validation