What you can do is to use WhenAsync. I have created an extension method to make things easier.
public static class ValidatorExtensions
{
public static void ResolveDataAsync<TEntity, TData>(
this AbstractValidator<TEntity> validator,
Func<TEntity, CancellationToken, Task<TData>> resolver,
Action<ValueAccessor<TData>> continuation)
{
TData data = default;
var isInitialized = false;
var valueAccessor = new ValueAccessor<TData>(() =>
{
if (!isInitialized)
{
throw new InvalidOperationException("Value is not initialized at this point.");
}
return data;
});
validator.WhenAsync(async (entity, token) =>
{
data = await resolver(entity, token);
return isInitialized = true;
},
() => continuation(valueAccessor));
}
}
public class ValueAccessor<T>
{
private readonly Func<T> _accessor;
public ValueAccessor([NotNull] Func<T> accessor)
{
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
}
public T Value => _accessor();
}
Usage:
public class ItemCreateCommandValidator : AbstractValidator<ItemCreateCommand>
{
private readonly ICategoryRepository _categoryRepository;
public ItemCreateCommandValidator(ICategoryRepository categoryRepository)
{
_categoryRepository = categoryRepository;
this.ResolveDataAsync(CategoryResolver, data =>
{
RuleFor(x => x.CategoryIds)
.NotEmpty()
.ForEach(subcategoryRule => subcategoryRule
.Must(x => data.Value.ContainsKey(x))
.WithMessage((_, id) => $"Category with id {id} not found."));
});
}
private Func<ItemCreateCommand, CancellationToken, Task<Dictionary<int, Category>>> CategoryResolver =>
async (command, token) =>
{
var categories = await _categoryRepository.GetByIdsAsync(command.SubcategoryIds, token);
return categories.ToDictionary(x => x.Id);
};
}
Works fine to me, but there are a few GOTCHAS:
The validator usually have to be defined as Scoped or Transient (Scoped is better for performance) in order to be compatible with lifecycle of it's dependencies (e.g. repository passed in constructor).
You can't access the data.Value
right inside ResolveDataAsync callback. This is because the value is not initialized by that time. By this time validator is in creation phase and ValidateAsync method was not called => nothing to validate => value can't be accessed.
It can be used only in AbstractValidator methods:
this.ResolveDataAsync(CategoryResolver, data =>
{
var value = data.Value; // Throws InvalidOperationException
RuleFor(x => x.CategoryIds)
.NotEmpty()
.ForEach(subcategoryRule => subcategoryRule
.Must(data.Value.ContainsKey) // Also throws
.WithMessage((_, id) => $"Category with id {id} not found."));
});
These gotchas also occur with other approaches, such as overriding the ValidateAsync method, and there is not much you can do about them.
You can also call ResolveDataAsync with different resolvers depending on condition when using WhenAsync
, UnlessAsync
. This will help you not to load data that is not needed in all cases every time:
WhenAsync(myCondition1, () => this.ResolveDataAsync(myResolver1, data => { ... }))
UnlessAsync(myCondition2, () => this.ResolveDataAsync(myResolver2, data => { ... }))