Skip to main content

Overview

Domain validators ensure that entities remain in a valid state according to business rules. The architecture uses FluentValidation to provide declarative, fluent validation rules that are automatically applied to entities.

Base Validator Class

EntityValidator

All domain validators inherit from EntityValidator<TEntity>, which is located in Core.Domain.Validators.EntityValidator.
TEntity
generic
The entity type to validate. Must inherit from DomainEntity<TKey, TValidator>
The base class extends FluentValidation’s AbstractValidator<TEntity> and provides the foundation for defining validation rules.
using FluentValidation;

namespace Core.Domain.Validators
{
    public class EntityValidator<TEntity> : AbstractValidator<TEntity>
    {
    }
}

Creating Custom Validators

Basic Validator Example

Here’s how to create a validator for your domain entities, based on DummyEntityValidator from Domain/Validators/DummyEntityValidator.cs:13:
using Core.Domain.Validators;
using Domain.Constants;
using Domain.Entities;
using FluentValidation;

namespace Domain.Validators
{
    public class DummyEntityValidator : EntityValidator<DummyEntity>
    {
        public DummyEntityValidator()
        {
            // Define business rules in the constructor
            RuleFor(x => x.DummyPropertyOne)
                .NotNull()
                .NotEmpty()
                .WithMessage(DomainConstants.NOTNULL_OR_EMPTY);
        }
    }
}

Advanced Validator Example

using Core.Domain.Validators;
using Domain.Constants;
using Domain.Entities;
using FluentValidation;

namespace Domain.Validators
{
    public class CustomerValidator : EntityValidator<Customer>
    {
        public CustomerValidator()
        {
            // Required fields
            RuleFor(x => x.Name)
                .NotEmpty().WithMessage("Customer name is required")
                .MaximumLength(100).WithMessage("Name cannot exceed 100 characters");

            RuleFor(x => x.Email)
                .NotNull().WithMessage("Email is required")
                .EmailAddress().WithMessage("Invalid email format");

            // Conditional validation
            RuleFor(x => x.Age)
                .GreaterThanOrEqualTo(18)
                .When(x => x.RequiresAgeVerification)
                .WithMessage("Customer must be at least 18 years old");

            // Complex validation
            RuleFor(x => x.CreditLimit)
                .GreaterThan(0).WithMessage("Credit limit must be positive")
                .LessThanOrEqualTo(100000).WithMessage("Credit limit cannot exceed 100,000");

            // Custom validation logic
            RuleFor(x => x.PhoneNumber)
                .Must(BeValidPhoneNumber)
                .WithMessage("Invalid phone number format");

            // Collection validation
            RuleForEach(x => x.Addresses)
                .SetValidator(new AddressValidator());
        }

        private bool BeValidPhoneNumber(string phoneNumber)
        {
            if (string.IsNullOrWhiteSpace(phoneNumber))
                return false;

            // Simple validation - adjust regex based on requirements
            return System.Text.RegularExpressions.Regex.IsMatch(
                phoneNumber, 
                @"^\+?[1-9]\d{1,14}$"
            );
        }
    }
}

Validation Rules

Common FluentValidation Rules

RuleFor(x => x.Name)
    .NotNull().WithMessage("Name is required")
    .NotEmpty().WithMessage("Name cannot be empty");
RuleFor(x => x.Description)
    .MinimumLength(10).WithMessage("Description must be at least 10 characters")
    .MaximumLength(500).WithMessage("Description cannot exceed 500 characters")
    .Length(10, 500).WithMessage("Description must be between 10 and 500 characters");
RuleFor(x => x.Quantity)
    .GreaterThan(0).WithMessage("Quantity must be positive")
    .LessThanOrEqualTo(1000).WithMessage("Quantity cannot exceed 1000")
    .InclusiveBetween(1, 1000).WithMessage("Quantity must be between 1 and 1000");
RuleFor(x => x.Email)
    .EmailAddress().WithMessage("Invalid email format");

RuleFor(x => x.PostalCode)
    .Matches(@"^\d{5}(-\d{4})?$").WithMessage("Invalid postal code format");
RuleFor(x => x.CompanyName)
    .NotEmpty()
    .When(x => x.IsBusinessCustomer)
    .WithMessage("Company name is required for business customers");
RuleFor(x => x.OrderDate)
    .Must(BeValidBusinessDay)
    .WithMessage("Orders can only be placed on business days");

private bool BeValidBusinessDay(DateTime date)
{
    return date.DayOfWeek != DayOfWeek.Saturday && 
           date.DayOfWeek != DayOfWeek.Sunday;
}
RuleFor(x => x.Items)
    .NotEmpty().WithMessage("Order must contain at least one item")
    .Must(x => x.Count <= 50).WithMessage("Order cannot contain more than 50 items");

RuleForEach(x => x.Items)
    .SetValidator(new OrderItemValidator());

Integration with Entities

How Validation Works

Entities automatically use their validators through the DomainEntity<TKey, TValidator> base class:
// Entity definition with validator
public class Customer : DomainEntity<Guid, CustomerValidator>
{
    public string Name { get; private set; }
    public string Email { get; private set; }
    
    // ... entity implementation
}

// Using the entity
var customer = new Customer("John Doe", "john@example.com");

// Check if valid
if (!customer.IsValid)
{
    var errors = customer.GetErrors();
    foreach (var error in errors)
    {
        Console.WriteLine($"{error.PropertyName}: {error.ErrorMessage}");
    }
}

Validation Lifecycle

Validation occurs automatically when:
  1. Accessing IsValid property - Triggers validation and returns boolean result
  2. Calling GetErrors() - Triggers validation and returns detailed error list
public class DomainEntity<TKey, TValidator> : IValidate
    where TValidator : IValidator, new()
{
    public bool IsValid
    {
        get
        {
            Validate();
            return ValidationResult.IsValid;
        }
    }

    protected void Validate()
    {
        var context = new ValidationContext<object>(this);
        ValidationResult = Validator.Validate(context);
    }

    public IList<ValidationFailure> GetErrors()
    {
        Validate();
        return ValidationResult.Errors;
    }
}

Validation Patterns

Fail-Fast Validation

Stop validation at first failure:
public class ProductValidator : EntityValidator<Product>
{
    public ProductValidator()
    {
        // Configure to stop at first failure
        ClassLevelCascadeMode = CascadeMode.Stop;

        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

Dependent Rules

Validate properties only if previous rules passed:
public class OrderValidator : EntityValidator<Order>
{
    public OrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .NotEmpty().WithMessage("Customer is required");

        RuleFor(x => x.Total)
            .GreaterThan(0)
            .DependentRules(() =>
            {
                RuleFor(x => x.PaymentMethod)
                    .NotNull().WithMessage("Payment method required for orders with total > 0");
            });
    }
}

Reusable Validators

Extract common validation logic:
public static class CommonValidators
{
    public static IRuleBuilderOptions<T, string> IsValidEmail<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .NotEmpty().WithMessage("Email is required")
            .EmailAddress().WithMessage("Invalid email format")
            .MaximumLength(255).WithMessage("Email too long");
    }

    public static IRuleBuilderOptions<T, string> IsValidPhoneNumber<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .NotEmpty()
            .Matches(@"^\+?[1-9]\d{1,14}$")
            .WithMessage("Invalid phone number format");
    }
}

// Usage
public class ContactValidator : EntityValidator<Contact>
{
    public ContactValidator()
    {
        RuleFor(x => x.Email).IsValidEmail();
        RuleFor(x => x.Phone).IsValidPhoneNumber();
    }
}

Error Handling

ValidationFailure Properties

Each validation error provides:
PropertyName
string
The name of the property that failed validation
ErrorMessage
string
The human-readable error message
AttemptedValue
object
The value that failed validation
ErrorCode
string
Optional error code for programmatic handling

Checking Validation Results

var entity = new DummyEntity(null, DummyValues.value1);

if (!entity.IsValid)
{
    var errors = entity.GetErrors();
    
    // Get all error messages
    var errorMessages = errors.Select(e => e.ErrorMessage).ToList();
    
    // Get errors for specific property
    var propertyErrors = errors
        .Where(e => e.PropertyName == "DummyPropertyOne")
        .Select(e => e.ErrorMessage)
        .ToList();
    
    // Format for logging
    var formattedErrors = string.Join(", ", 
        errors.Select(e => $"{e.PropertyName}: {e.ErrorMessage}"));
    
    Console.WriteLine($"Validation failed: {formattedErrors}");
}

Best Practices

Define Rules in Constructor

All validation rules should be defined in the validator’s constructor for clarity and consistency.

Use Domain Constants

Reference constants from Domain.Constants for consistent error messages across validators.

Validate Business Rules

Validators should enforce business rules, not just data format. This is where domain knowledge lives.

Keep Validators Focused

Each validator should validate one entity type. Don’t create mega-validators.

Testing Validators

using Xunit;
using FluentValidation.TestHelper;

public class DummyEntityValidatorTests
{
    private readonly DummyEntityValidator _validator;

    public DummyEntityValidatorTests()
    {
        _validator = new DummyEntityValidator();
    }

    [Fact]
    public void Should_Have_Error_When_DummyPropertyOne_Is_Null()
    {
        var entity = new DummyEntity(null, DummyValues.value1);
        var result = _validator.TestValidate(entity);
        result.ShouldHaveValidationErrorFor(x => x.DummyPropertyOne);
    }

    [Fact]
    public void Should_Not_Have_Error_When_DummyPropertyOne_Is_Valid()
    {
        var entity = new DummyEntity("Valid Value", DummyValues.value1);
        var result = _validator.TestValidate(entity);
        result.ShouldNotHaveValidationErrorFor(x => x.DummyPropertyOne);
    }
}

Build docs developers (and LLMs) love