By Varun Om | February 7, 2020
In ISCP we started validating requests with ServiceStack Fluent Validation (about five years ago). This was limited to pure input pattern validation (let's call it field validation) with no regards to correctness of the same in relation to the data in the database (business validation). The latter was done inside service method (if required). Later we introduced a service-agnostic manager layer and moved logic from service methods to managers. At managers we opted to creating separate validator types to offload business validation logic from manager methods for readability purposes. This way we now got two separate validators for an endpoint at different layers often having some redundancy of rules. Integrated validation is the latest approach I've designed to bring together these two sets of validation logic together under a common validator called Aggregate Validator.
Design Principles
While designing integrated validation approach I ensure to adhere to the following principles:
- Separation of input pattern and business validation: It was decided that we should continue to keep pattern validation and business rules separate. A POC was done which brought both of them together but was considered ugly and difficult to understand.
- Leverage fluent validation for input pattern validation. This is so that we could just migrate existing validators from service layer as is and take advantage of the declarative approach more suitable for pattern validation.
- A lightweight framework for business validation. The POC also demonstrated that business rules weren't suitable to be written in declarative fashion especially when we needed to get data from the database as part of the rule logic. However I also felt that the current crude form of business rules should be upgraded through some helper extensions and automation to make them shorter, simple and easily understandable
Command Validator
We call request types as commands in ISCP. ICommandValidator
is the interface that both types of validators (business and input pattern) must implement. There are two variants of this interface based on number of generic parameters:
ICommandValidator<TCommand>
- For endpoints with only input to validate we prefer the command-only validator. For example create endpoints.
public interface ICommandValidator<in TCommand>
{
Task<CommandValidationResult> ValidateAsync(TCommand command, CancellationToken token = default);
}
ICommandValidator<TCommand, TDbObject>
- For endpoints (like update ones) we do want to inspect existing record in the validator so we prefer the two-param validator.
public interface ICommandValidator<in TCommand, in TDbObject>
{
Task<CommandValidationResult> ValidateAsync(TCommand command, TDbObject dbObject, CancellationToken token = default);
}
Aggregate Validator
IAggregateValidator
is the main interface for invoking integrated validation for a given command in managers. It has the same number of variants (two) as that of ICommandValidator
each having identical interface definition:
IAggregateValidator<TCommand>
public interface IAggregateValidator<in TCommand> where TCommand : class
{
Task<CommandValidationResult> ValidateAsync(TCommand command, CancellationToken token = default);
}
IAggregateValidator<TCommand, TDbObject>
public interface IAggregateValidator<in TCommand, in TDbObject>
where TCommand : class
where TDbObject : DataAccessObject
{
Task<CommandValidationResult> ValidateAsync(TCommand command, TDbObject dbObject, CancellationToken token = default);
}
An example of how the validator is invoked inside a manager method:
public class AccountManager
{
private readonly IAggregateValidator<CreateAccountCommand> createAccountValidator;
public AccountManager(IAggregateValidator<CreateAccountCommand> createAccountValidator)
{
this.createAccountValidator = createAccountValidator;
}
public async Task<Account> CreateAccountAsync(
CreateAccountCommand command,
CancellationToken token = default)
{
(await this.createAccountValidator.ValidateAsync(command, token).ConfigureAwait(false))
.ThrowIfAnyErrors();
...
}
}
Implementing The Command Validators
- In the Managers.Validators project, create an appropriate folder (if it doesn't exist) for the type of entity for which you are implementing the validation. For example, Team, Client or Account
- For business rules implement a validator (in the above folder) deriving from appropriate
BaseBusinessValidator
that implements the desiredICommandValidator
based on whichIAggregateValidator
variant you want to leverage. (more about this in the next section.) The naming convention is <commandName>BusinessValidator. For example:
public class CreateAccountBusinessValidator : BaseBusinessValidator<CreateAccountCommand>
{
private readonly IUserQuery userQuery;
public CreateAccountBusinessValidator(IUserQuery userQuery)
{
this.userQuery = userQuery;
}
public async Task<CommandValidationResult> ValidateAsync(CreateAccountCommand command, CancellationToken token = default)
{
await base.ValidateAsync(command, token).ConfigureAwait(false); // Initializes Result and Command properties (see Base Validators section of this post)
if (!await this.userQuery.UserExistsAsync(command.Account.UserId, token).ConfigureAwait(false))
{
return NotFound(x => x.UserId);
}
return this.Result;
}
}
- While implementing the business validator, take advantage of methods such as AddError, NotFound, etc. provided by
BaseBusinessValidator
. - For pattern rules implement a validator (in the above folder) deriving from the appropriate
BaseFieldValidator
that implements the desiredICommandValidator
based on which IAggregateValidator variant you want to leverage. The naming convention is <commandName>FieldValidator. For example:
public class CreateAccountFieldValidator : BaseFieldValidator<CreateAccountCommand>
{
public CreateAccountFieldValidator(
IValidator<Account> accountValidator)
{
RuleFor(x => x.Account)
.NotNull()
.WhenParentsNotNull();
RuleFor(x => x.Account)
.SetValidator(accountValidator)
.When(x => x.Account != null)
.WhenParentsNotNull();
}
}
Magic of IAggregateValidator
Implementation of IAggregateValidator
actually injects an array of ICommandValidator
(using dependency injection - we use StructureMap in ISCP)and executes them in a specific order. Both variants of IAggregateValidator
have their own separate implementations and hence it's crucial that all validators for a given command must be implementing (or deriving from) the same ICommandValidator
that matches the specific IAggregateValidator
being consumed. However, it's easy to miss this requirement by developers especially while implementing, say a field validator, because the fluent rules are declared in constructor and isn't suppose to use second param (DbObject
). If that happens, such a validator won't be injected and evaluated, causing unvalidated data to pass through to the database. To guard against such a possibility, I wrote a unit test that fails with list of commands it finds each having validators with different ICommandValidator
interface.
[TestFixture]
public class IntegrityCheckTests
{
[Test]
public void EnsureThatAllValidatorsWithSameCommandImplementsSameICommandValidator()
{
var singleName = typeof(ICommandValidator<>).Name;
var doubleName = typeof(ICommandValidator<,>).Name;
var nonConformingCommands = typeof(BaseBusinessValidator).Assembly.GetTypes()
.Where(x => x.IsClass && !x.IsAbstract && (x.GetInterfaces().Any(y => y.Name == singleName || y.Name == doubleName)))
.GroupBy(x => (x.GetInterface(singleName) ?? x.GetInterface(doubleName)).GetGenericArguments()[0])
// Either noone implements the double param interface or all of them. Get the ones which do not conform to above.
.Where(x => !(!x.Any(y => y.GetInterface(doubleName) != null) || x.All(y => y.GetInterface(doubleName) != null)))
.ToList();
nonConformingCommands.Count.ShouldBe(0, $"Following commands have validators not implementing the same ICommandValidator interface: {string.Join(", ", nonConformingCommands.Select(x => x.Key.Name))}.");
}
}
The order of evaluation:
- All field (pattern) validators are evaluated in no specific order and the errors returned are consolidated in one collection.
- No business Validator is evaluated if there's an error in prior step
- business Validator are evaluated in no specific order and as soon as the first error is received evaluation is stopped and error is returned. This is because business rules often depend on data validated in prior rules and hence can't proceed evaluating subsequent rules upon failure.
public sealed class AggregateValidator<TCommand> : IAggregateValidator<TCommand>
where TCommand : class
{
private readonly ICommandValidator<TCommand>[] validators;
public AggregateValidator(ICommandValidator<TCommand>[] validators)
{
this.validators = validators;
}
public async Task<CommandValidationResult> ValidateAsync(TCommand command, CancellationToken token = default)
{
var fieldValidators = this.validators.Where(x => x is FluentValidation.IValidator).ToList();
var businessValidators = this.validators.Except(fieldValidators).ToList();
var result = new CommandValidationResult();
foreach (var validator in fieldValidators)
{
result.Errors.AddRange((await validator.ValidateAsync(command, token).ConfigureAwait(false)).Errors);
}
if (!result.IsValid)
{
return result;
}
foreach (var validator in businessValidators)
{
result = await validator.ValidateAsync(command, token).ConfigureAwait(false);
if (!result.IsValid)
{
return result;
}
}
return CommandValidationResult.Success;
}
}
The other variation (with TDbObject
additional generic param) is exactly the same except the second generic param is to be added.
Base Validators
BaseBusinessValidator
(Two variations) - It provides helper methods to generate error messages without needing to repeat the same boilerplate code in the validators:
public abstract class BaseBusinessValidator<TCommand> : ICommandValidator<TCommand>
{
private static ConcurrentDictionary<string, object> compiledDelegates = new ConcurrentDictionary<string, object>();
private static readonly Regex memberAccessPattern = new Regex(@"^\w+\.((\w+\.)*\w+)$");
private TCommand Command;
protected CommandValidationResult Result { get; private set; }
public virtual async Task<CommandValidationResult> ValidateAsync(TCommand command, CancellationToken token = default)
{
this.Result = new CommandValidationResult();
this.Command = command;
return this.Result;
}
protected void AddError<TProperty>(Expression<Func<TCommand, TProperty>> memberExpression, string errorMessage, ErrorCodes errorCode)
{
this.Result.Errors.Add(new ValidationError
{
PropertyName = GetPropertyName(memberExpression),
Error = errorMessage,
ErrorCode = errorCode.ToString()
});
}
protected CommandValidationResult NotFound<TProperty>(Expression<Func<TCommand, TProperty>> memberExpression)
{
this.Result.Errors.Add(new ValidationError
{
PropertyName = GetPropertyName(memberExpression),
Error = $"Record [ID = {GetPropertyStringValue(this.Command, memberExpression)}] not found",
ErrorCode = ErrorCodes.NotFound.ToString()
});
return this.Result;
}
private static string GetPropertyName<TProperty>(Expression<Func<TCommand, TProperty>> memberExpression)
{
var match = memberAccessPattern.Match(memberExpression.Body.ToString());
if (match.Success)
{
return match.Groups[1].Value;
}
throw new InvalidOperationException($"{memberExpression} is not a valid member access expression.");
}
private static string GetPropertyStringValue<TProperty>(TCommand command, Expression<Func<TCommand, TProperty>> memberExpression)
{
var key = $"{typeof(TCommand).FullName}.{memberExpression.Body}";
var getValue = (Func<TCommand, TProperty>)compiledDelegates.GetOrAdd(key, x => memberExpression.Compile());
var value = getValue(command);
return typeof(TProperty) == typeof(Guid) || typeof(TProperty) == typeof(Guid?)
? $"{value:N}" : value.ToString();
}
}
public abstract class BaseBusinessValidator<TCommand, TDbObject> : BaseBusinessValidator<TCommand>, ICommandValidator<TCommand, TDbObject>
{
public sealed override Task<CommandValidationResult> ValidateAsync(TCommand command, CancellationToken token = default) => throw new NotSupportedException("Use the other overload instead.");
public virtual Task<CommandValidationResult> ValidateAsync(TCommand command, TDbObject dbObject, CancellationToken token = default) => base.ValidateAsync(command, token);
}
BaseFieldValidator
(Two variations) - It extends FluentValidation.AbstractValidator to transform FV's error result object into our CommandValidationResult object:
public abstract class BaseFieldValidator<TCommand> : AbstractValidator<TCommand>, ICommandValidator<TCommand>
{
async Task<CommandValidationResult> ICommandValidator<TCommand>.ValidateAsync(TCommand command, CancellationToken token)
{
var result = await ValidateAsync(command, token).ConfigureAwait(false);
return result.IsValid ? CommandValidationResult.Success : ToCommandValidationResult(result);
}
protected static CommandValidationResult ToCommandValidationResult(ValidationResult result) => new CommandValidationResult(result.Errors.Select(x => new ValidationError
{
PropertyName = x.PropertyName,
ErrorCode = x.ErrorCode,
Error = x.ErrorMessage
}).ToList());
}
public abstract class BaseFieldValidator<TCommand, TDbObject> : BaseFieldValidator<TCommand>, ICommandValidator<TCommand, TDbObject>
{
public async Task<CommandValidationResult> ValidateAsync(TCommand command, TDbObject dbObject, CancellationToken token = default)
{
var result = await ValidateAsync(command, token).ConfigureAwait(false);
return result.IsValid ? CommandValidationResult.Success : ToCommandValidationResult(result);
}
}
CommandValidationResult
- It provides a common type to consolidate and report result of validation operations:
public class CommandValidationResult
{
public static CommandValidationResult Success { get; } = new CommandValidationResult();
public CommandValidationResult()
: this(new List<ValidationError>())
{
}
public CommandValidationResult(List<ValidationError> errors)
{
this.Errors = errors;
}
public bool IsValid => this.Errors.Count == 0;
public List<ValidationError> Errors { get; }
public void ThrowIfAnyErrors()
{
if (this.IsValid)
{
return;
}
throw new ValidationException(this.Errors);
}
}
public class ValidationError
{
public string PropertyName { get; set; }
public string Error { get; set; }
public string ErrorCode { get; set; }
}
Testing Guidelines
- Write a validator test for each validator you implement in Managers.Validators.Tests under an appropriate folder based on the type of entity validated.
- Ensure that there's at least one test for each validator within the manager integration tests for that command. Just put this test inside the managerTests class; no need for a separate validator test in Managers.IntegrationTests. My recommendation is not to write more than one integration test because the comprehensive validation unit tests are already written with the first step above.