What I love a about data annotations and validation attributes, is that the validation rules are defined very close to the class: when you open it up, you can view all the rules. The ApiController will even automatically do model validation. This is great, but I like some defense it depth; I want to be able to use those validations in my business service as well. I should not have to trust input. Let's explore how we can make that happen.
Let's explore the problem
When you want to use data annotation validation in your own classes, you can do:
using System.ComponentModel.DataAnnotations;
IList<ValidationResult> validationErrors = [];
var context = new ValidationContext(obj);
var act = () => Validator.TryValidateObject(
obj,
context,
validationErrors,
true);This works okay. It will validate the object and return the errors in a nice list. But what if we want to use a service that is injected in the validator? Well... you'll get a nice InvalidOperationException with the message: No service for type 'xxx' has been registered.
Fortunately, this can be solved by supplying a service provider to the context:
var context = new ValidationContext(obj, provider, null);This makes the validation mechanism very powerful. But we still end up with quite some manual work. So let's see if we can abstract the orchestration into a single object that we can reuse in our business classes.
Enter the IDataAnnotationsValidator
Now, what if we have a IDataAnnotationsValidator to perform the validation?
public interface IDataAnnotationsValidator
{
bool TryValidate(object obj);
bool TryValidate(
object obj,
out IList<ValidationResult> validationErrors
);
void ThrowIfInvalid(
object argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null
);
}
This means we could easily inject and use validation it our service:
public class ProvisionerService(IDataAnnotationsValidator validator)
{
public void ProvisionApplication(SimpleApplication request)
{
ArgumentNullException.ThrowIfNull(request);
validator.ThrowIfInvalid(request);
// continue
}
public void ProvisionApplication(ComplexApplication request)
{
ArgumentNullException.ThrowIfNull(request);
validator.ThrowIfInvalid(request);
// continue
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Mimics how actual services work.")]
public Task<string[]> GetApplicationNames()
{
return Task.FromResult(new string[]
{
"app-name-taken",
"no-such-app-name",
});
}
public async Task<bool> Exists(string name)
{
var applications = await GetApplicationNames();
return applications.Contains(name);
}
}
DataAnnotationsValidator implementation
Let's implement the IDataAnnotationsValidator by injecting the IServiceProvider into the class.
public class DataAnnotationsValidator(IServiceProvider provider) : IDataAnnotationsValidator
{
protected virtual bool Validate(object obj, out IList<ValidationResult> validationErrors)
{
validationErrors = [];
var context = new ValidationContext(obj, provider, null);
var valid = Validator.TryValidateObject(obj, context, validationErrors, true);
return valid;
}
public bool TryValidate(object obj) =>
Validate(obj, out _);
public bool TryValidate(object obj, out IList<ValidationResult> validationErrors) =>
Validate(obj, out validationErrors);
public void ThrowIfInvalid(
object argument,
[CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
var valid = Validate(argument, out IList<ValidationResult> validationErrors);
if (valid)
{
return;
}
var msg = GetErrorMessage(validationErrors, argument?.GetType());
Exception ex = new ValidationException(msg);
if (!string.IsNullOrEmpty(paramName))
{
ex = new ArgumentException($"Value is invalid.", paramName, ex);
}
throw ex;
}
public static string GetErrorMessage(IList<ValidationResult> validationErrors, Type? objectType)
{
ArgumentNullException.ThrowIfNull(validationErrors);
var message = "Input invalid";
if (objectType != null)
{
message += $" for '{objectType.Name}'";
}
message += ":\n";
message += string.Join("\n",
validationErrors.Select(r =>
string.Join(", ", r.MemberNames) +
": " +
r.ErrorMessage
)
);
return message;
}
}
Dependency injection
We only need to hook things up using our service collection, and we're good to go:
services.AddTransient<IDataAnnotationsValidator, DataAnnotationsValidator>();
services.AddTransient<ProvisionerService>();
services.AddTransient((serviceProvider) => serviceProvider);Conclusion
With the .NET data annotation validation framework, you have a powerful mechanism to validate your objects. They are not restricted to the API, but can also be used in your business logic to add some defense in dept.
The code is on GitHub, so check it out: code gallery / 12. HTTP and resilience.