skills/thapaliyabikendra/ai-artifacts/fluentvalidation-patterns

fluentvalidation-patterns

SKILL.md

FluentValidation Patterns

FluentValidation patterns for ABP Framework input DTO validation.

When to Use

  • Creating validators for Create/Update DTOs
  • Implementing async validation with repository checks
  • Building conditional validation rules
  • Creating reusable custom validators
  • Localizing validation error messages

ABP Integration

Basic Validator Structure

// Application/{Feature}/CreateUpdate{Entity}DtoValidator.cs
public class CreateUpdate{Entity}DtoValidator : AbstractValidator<CreateUpdate{Entity}Dto>
{
    public CreateUpdatePatientDtoValidator()
    {
        RuleFor(x => x.FirstName)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.LastName)
            .NotEmpty()
            .MaximumLength(100);

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MaximumLength(255);

        RuleFor(x => x.DateOfBirth)
            .NotEmpty()
            .LessThan(DateTime.Today)
            .WithMessage("Date of birth must be in the past");
    }
}

ABP Module Registration

// ApplicationModule.cs
public override void PreConfigureServices(ServiceConfigurationContext context)
{
    PreConfigure<AbpFluentValidationAutoValidationOptions>(options =>
    {
        options.AutoValidateMethodInvocations = true;
    });
}

public override void ConfigureServices(ServiceConfigurationContext context)
{
    // Auto-register all validators in assembly
    context.Services.AddValidatorsFromAssembly(typeof({ProjectName}ApplicationModule).Assembly);
}

Common Validation Rules

String Validation

RuleFor(x => x.Name)
    .NotEmpty()
    .WithMessage("Name is required")
    .MinimumLength(2)
    .MaximumLength(100)
    .Matches(@"^[a-zA-Z\s]+$")
    .WithMessage("Name can only contain letters and spaces");

RuleFor(x => x.Email)
    .NotEmpty()
    .EmailAddress()
    .Must(email => !email.EndsWith("@test.com"))
    .WithMessage("Test emails are not allowed");

RuleFor(x => x.Phone)
    .NotEmpty()
    .Matches(@"^\+?[1-9]\d{1,14}$")
    .WithMessage("Invalid phone number format");

Numeric Validation

RuleFor(x => x.Age)
    .InclusiveBetween(0, 150)
    .WithMessage("Age must be between 0 and 150");

RuleFor(x => x.Price)
    .GreaterThan(0)
    .LessThanOrEqualTo(1000000)
    .PrecisionScale(10, 2, false);

RuleFor(x => x.Quantity)
    .GreaterThanOrEqualTo(1)
    .When(x => x.IsRequired);

Date Validation

RuleFor(x => x.DateOfBirth)
    .NotEmpty()
    .LessThan(DateTime.Today)
    .GreaterThan(DateTime.Today.AddYears(-150));

RuleFor(x => x.AppointmentDate)
    .NotEmpty()
    .GreaterThan(DateTime.Now)
    .WithMessage("Appointment must be in the future");

RuleFor(x => x.EndDate)
    .GreaterThan(x => x.StartDate)
    .When(x => x.EndDate.HasValue)
    .WithMessage("End date must be after start date");

Collection Validation

RuleFor(x => x.Tags)
    .NotEmpty()
    .Must(tags => tags.Count <= 10)
    .WithMessage("Maximum 10 tags allowed");

RuleForEach(x => x.Items)
    .ChildRules(item =>
    {
        item.RuleFor(i => i.ProductId).NotEmpty();
        item.RuleFor(i => i.Quantity).GreaterThan(0);
    });

RuleFor(x => x.Emails)
    .Must(emails => emails.Distinct().Count() == emails.Count)
    .WithMessage("Duplicate emails are not allowed");

Async Validation with Repository

Unique Email Check

public class CreateUpdate{Entity}DtoValidator : AbstractValidator<CreateUpdate{Entity}Dto>
{
    private readonly IRepository<{Entity}, Guid> _repository;

    public CreateUpdate{Entity}DtoValidator(IRepository<{Entity}, Guid> repository)
    {
        _repository = repository;

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(BeUniqueEmail)
            .WithMessage("Email already exists");
    }

    private async Task<bool> BeUniqueEmail(string email, CancellationToken cancellationToken)
    {
        return !await _repository.AnyAsync(e => e.Email == email);
    }
}

Unique Check with Edit Context

public class CreateUpdate{Entity}DtoValidator : AbstractValidator<CreateUpdate{Entity}Dto>
{
    private readonly IRepository<{Entity}, Guid> _repository;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CreateUpdate{Entity}DtoValidator(
        IRepository<{Entity}, Guid> repository,
        IHttpContextAccessor httpContextAccessor)
    {
        _repository = repository;
        _httpContextAccessor = httpContextAccessor;

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(BeUniqueEmailAsync)
            .WithMessage("Email already exists");
    }

    private async Task<bool> BeUniqueEmailAsync(string email, CancellationToken cancellationToken)
    {
        // Get entity ID from route (for updates)
        var routeData = _httpContextAccessor.HttpContext?.GetRouteData();
        var idString = routeData?.Values["id"]?.ToString();

        if (Guid.TryParse(idString, out var entityId))
        {
            // Exclude current entity from check (update scenario)
            return !await _repository.AnyAsync(
                e => e.Email == email && e.Id != entityId);
        }

        // Create scenario
        return !await _repository.AnyAsync(e => e.Email == email);
    }
}

Foreign Key Exists Check

public class Create{Entity}DtoValidator : AbstractValidator<Create{Entity}Dto>
{
    private readonly IRepository<{ParentEntity}, Guid> _parentRepository;
    private readonly IRepository<{RelatedEntity}, Guid> _relatedRepository;

    public Create{Entity}DtoValidator(
        IRepository<{ParentEntity}, Guid> parentRepository,
        IRepository<{RelatedEntity}, Guid> relatedRepository)
    {
        _parentRepository = parentRepository;
        _relatedRepository = relatedRepository;

        RuleFor(x => x.{ParentEntity}Id)
            .NotEmpty()
            .MustAsync(ParentExistsAsync)
            .WithMessage("{ParentEntity} not found");

        RuleFor(x => x.{RelatedEntity}Id)
            .NotEmpty()
            .MustAsync(RelatedExistsAsync)
            .WithMessage("{RelatedEntity} not found");
    }

    private async Task<bool> ParentExistsAsync(Guid id, CancellationToken ct)
    {
        return await _parentRepository.AnyAsync(e => e.Id == id);
    }

    private async Task<bool> RelatedExistsAsync(Guid id, CancellationToken ct)
    {
        return await _relatedRepository.AnyAsync(e => e.Id == id);
    }
}

Conditional Validation

When/Unless

RuleFor(x => x.CompanyName)
    .NotEmpty()
    .When(x => x.CustomerType == CustomerType.Business)
    .WithMessage("Company name is required for business customers");

RuleFor(x => x.TaxId)
    .NotEmpty()
    .Matches(@"^\d{9}$")
    .When(x => x.CustomerType == CustomerType.Business);

RuleFor(x => x.BirthDate)
    .NotEmpty()
    .Unless(x => x.CustomerType == CustomerType.Business);

Complex Conditional Logic

RuleFor(x => x.EndDate)
    .NotEmpty()
    .GreaterThan(x => x.StartDate)
    .When(x => x.IsRecurring && x.RecurrenceType != RecurrenceType.Indefinite);

When(x => x.PaymentMethod == PaymentMethod.CreditCard, () =>
{
    RuleFor(x => x.CardNumber).NotEmpty().CreditCard();
    RuleFor(x => x.ExpirationDate).NotEmpty().GreaterThan(DateTime.Today);
    RuleFor(x => x.CVV).NotEmpty().Length(3, 4);
});

When(x => x.PaymentMethod == PaymentMethod.BankTransfer, () =>
{
    RuleFor(x => x.BankAccountNumber).NotEmpty();
    RuleFor(x => x.RoutingNumber).NotEmpty();
});

Custom Validators

Reusable Property Validator

// Validators/PhoneNumberValidator.cs
public class PhoneNumberValidator<T> : PropertyValidator<T, string>
{
    public override string Name => "PhoneNumberValidator";

    public override bool IsValid(ValidationContext<T> context, string value)
    {
        if (string.IsNullOrEmpty(value))
            return true; // Let NotEmpty handle required check

        // E.164 format
        return Regex.IsMatch(value, @"^\+[1-9]\d{1,14}$");
    }

    protected override string GetDefaultMessageTemplate(string errorCode)
        => "'{PropertyName}' must be a valid phone number in E.164 format";
}

// Extension method for fluent usage
public static class ValidatorExtensions
{
    public static IRuleBuilderOptions<T, string> PhoneNumber<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder.SetValidator(new PhoneNumberValidator<T>());
    }
}

// Usage
RuleFor(x => x.Phone).PhoneNumber();

Async Custom Validator

public class UniqueEmailValidator<T> : AsyncPropertyValidator<T, string>
{
    private readonly IRepository<{Entity}, Guid> _repository;

    public UniqueEmailValidator(IRepository<{Entity}, Guid> repository)
    {
        _repository = repository;
    }

    public override string Name => "UniqueEmailValidator";

    public override async Task<bool> IsValidAsync(
        ValidationContext<T> context,
        string value,
        CancellationToken cancellation)
    {
        if (string.IsNullOrEmpty(value))
            return true;

        return !await _repository.AnyAsync(e => e.Email == value);
    }

    protected override string GetDefaultMessageTemplate(string errorCode)
        => "'{PropertyName}' must be unique";
}

Localization

Using ABP Localization

public class CreateUpdate{Entity}DtoValidator : AbstractValidator<CreateUpdate{Entity}Dto>
{
    private readonly IStringLocalizer<{ProjectName}Resource> _localizer;

    public CreateUpdate{Entity}DtoValidator(
        IStringLocalizer<{ProjectName}Resource> localizer)
    {
        _localizer = localizer;

        RuleFor(x => x.FirstName)
            .NotEmpty()
            .WithMessage(_localizer["Validation:FirstNameRequired"])
            .MaximumLength(100)
            .WithMessage(_localizer["Validation:FirstNameMaxLength", 100]);

        RuleFor(x => x.Email)
            .NotEmpty()
            .WithMessage(_localizer["Validation:EmailRequired"])
            .EmailAddress()
            .WithMessage(_localizer["Validation:EmailInvalid"]);
    }
}

// Localization JSON: Localization/{ProjectName}/en.json
{
  "Validation:FirstNameRequired": "First name is required",
  "Validation:FirstNameMaxLength": "First name cannot exceed {0} characters",
  "Validation:EmailRequired": "Email is required",
  "Validation:EmailInvalid": "Please enter a valid email address"
}

Validation in AppService

Manual Validation

public class {Entity}AppService : ApplicationService, I{Entity}AppService
{
    private readonly IValidator<CreateUpdate{Entity}Dto> _validator;

    public async Task<{Entity}Dto> CreateAsync(CreateUpdate{Entity}Dto input)
    {
        // Manual validation (if auto-validation disabled)
        var validationResult = await _validator.ValidateAsync(input);
        if (!validationResult.IsValid)
        {
            throw new AbpValidationException(
                validationResult.Errors
                    .Select(e => new ValidationResult(e.ErrorMessage, new[] { e.PropertyName }))
                    .ToList()
            );
        }

        // Proceed with creation
    }
}

Validation with Custom Context

public async Task<{Entity}Dto> UpdateAsync(Guid id, CreateUpdate{Entity}Dto input)
{
    var context = new ValidationContext<CreateUpdate{Entity}Dto>(input)
    {
        RootContextData =
        {
            ["EntityId"] = id
        }
    };

    var validationResult = await _validator.ValidateAsync(context);
    // ...
}

// In validator
private async Task<bool> BeUniqueEmailAsync(
    CreateUpdate{Entity}Dto dto,
    string email,
    ValidationContext<CreateUpdate{Entity}Dto> context,
    CancellationToken ct)
{
    var entityId = context.RootContextData.TryGetValue("EntityId", out var id)
        ? (Guid?)id
        : null;

    if (entityId.HasValue)
    {
        return !await _repository.AnyAsync(p => p.Email == email && p.Id != entityId);
    }

    return !await _repository.AnyAsync(p => p.Email == email);
}

Best Practices

  1. One validator per DTO - Keep validators focused
  2. Inject dependencies - Use constructor injection for repositories
  3. Use async for DB checks - Always use MustAsync for repository queries
  4. Localize messages - Use ABP localization for user-facing messages
  5. Reuse validators - Create custom validators for common patterns
  6. Conditional rules - Use When/Unless for context-dependent validation
  7. Order matters - Put cheap validations first, async last
  8. Handle null gracefully - Let NotEmpty handle required checks

Quality Checklist

  • Validator created for each input DTO
  • All required fields have NotEmpty() rule
  • String fields have MaximumLength() matching entity
  • Email fields use EmailAddress() validator
  • Foreign keys validated with MustAsync existence check
  • Unique constraints validated with repository check
  • Error messages are localized
  • Validators registered in module

Shared Knowledge

For foundational patterns, see the shared knowledge base:

Topic File Description
Naming conventions knowledge/conventions/naming.md Validator naming patterns
Validation example knowledge/examples/validation-chain.md Complete validation examples
Folder structure knowledge/conventions/folder-structure.md Validator file locations

Integration Points

This skill is used by:

  • abp-developer: DTO validator implementation
  • abp-code-reviewer: Validation pattern review
  • /generate:entity: Validator scaffolding
Weekly Installs
14
GitHub Stars
15
First Seen
Jan 26, 2026
Installed on
cursor13
opencode12
github-copilot12
gemini-cli11
codex11
kimi-cli10