ValidateBase<T> provides validation and rules capabilities without persistence tracking. Use it when you need business rules and validation but do not need to track modification state for database operations.

When to Use ValidateBase vs EntityBase

The choice between ValidateBase<T> and EntityBase<T> depends on whether you need persistence tracking.

Use EntityBase When

  • The object represents a domain entity that will be persisted
  • You need IsNew, IsModified, IsDeleted tracking
  • The object participates in Save() operations
  • Changes need to be tracked for database updates

Use ValidateBase When

  • The object needs validation but not persistence tracking
  • The object represents a transient form or wizard step
  • You are building complex input forms with multi-step validation
  • The object validates user input before creating a real entity
  • You need a validated DTO or command object

Decision Guide

Need Base Class
Database persistence EntityBase<T>
Parent-child in aggregate EntityBase<T>
Modification tracking EntityBase<T>
Validation only ValidateBase<T>
Wizard/form steps ValidateBase<T>
Transient input objects ValidateBase<T>
Value objects with validation ValidateBase<T>

Class Hierarchy

Base<T>
  └── ValidateBase<T>      <-- This class
        └── EntityBase<T>

ValidateBase<T> inherits from Base<T> (property management, UI binding) and adds validation capabilities. EntityBase<T> then adds persistence tracking on top.

Class Declaration

Declare validation classes as partial and inherit from ValidateBase<T>:

[Factory]
internal partial class PaymentForm : ValidateBase<PaymentForm>, IPaymentForm
{
    public PaymentForm(IValidateBaseServices<PaymentForm> services)
        : base(services) { }

    // Properties and rules...
}

Note the constructor parameter type: IValidateBaseServices<T> rather than IEntityBaseServices<T>.

Constructor Pattern

public PaymentForm(
    IValidateBaseServices<PaymentForm> services,
    ICreditCardValidationRule cardRule) : base(services)
{
    RuleManager.AddRule(cardRule);
}

The constructor:

  • Accepts IValidateBaseServices<T> (required)
  • Passes services to the base class
  • Adds any injected rules to the RuleManager

Use Cases

Wizard Steps

Multi-step forms where each step needs independent validation:

// Step 1: Personal Information
[Factory]
internal partial class PersonalInfoStep : ValidateBase<PersonalInfoStep>, IPersonalInfoStep
{
    public PersonalInfoStep(IValidateBaseServices<PersonalInfoStep> services)
        : base(services) { }

    [Required(ErrorMessage = "First name is required")]
    public partial string? FirstName { get; set; }

    [Required(ErrorMessage = "Last name is required")]
    public partial string? LastName { get; set; }

    [EmailAddress(ErrorMessage = "Valid email required")]
    public partial string? Email { get; set; }

    [Create]
    public void Create() { }
}

// Step 2: Address Information
[Factory]
internal partial class AddressStep : ValidateBase<AddressStep>, IAddressStep
{
    public AddressStep(IValidateBaseServices<AddressStep> services)
        : base(services) { }

    [Required]
    public partial string? Street { get; set; }

    [Required]
    public partial string? City { get; set; }

    [Required]
    [RegularExpression(@"^\d{5}(-\d{4})?$", ErrorMessage = "Invalid ZIP")]
    public partial string? ZipCode { get; set; }

    [Create]
    public void Create() { }
}

// Step 3: Payment Information
[Factory]
internal partial class PaymentStep : ValidateBase<PaymentStep>, IPaymentStep
{
    public PaymentStep(
        IValidateBaseServices<PaymentStep> services,
        ICreditCardRule creditCardRule) : base(services)
    {
        RuleManager.AddRule(creditCardRule);
    }

    [Required]
    [CreditCard(ErrorMessage = "Invalid card number")]
    public partial string? CardNumber { get; set; }

    [Required]
    public partial string? ExpirationDate { get; set; }

    [Required]
    [StringLength(4, MinimumLength = 3)]
    public partial string? CVV { get; set; }

    [Create]
    public void Create() { }
}

Blazor Wizard Usage:

@page "/registration"

<MudStepper @ref="_stepper" Linear="true">
    <MudStep Title="Personal Info"
             Completed="@_personalInfo.IsValid">
        <MudNeatooTextField For="() => _personalInfo.FirstName" Label="First Name" />
        <MudNeatooTextField For="() => _personalInfo.LastName" Label="Last Name" />
        <MudNeatooTextField For="() => _personalInfo.Email" Label="Email" />
    </MudStep>

    <MudStep Title="Address"
             Completed="@_address.IsValid">
        <MudNeatooTextField For="() => _address.Street" Label="Street" />
        <MudNeatooTextField For="() => _address.City" Label="City" />
        <MudNeatooTextField For="() => _address.ZipCode" Label="ZIP Code" />
    </MudStep>

    <MudStep Title="Payment"
             Completed="@_payment.IsValid">
        <MudNeatooTextField For="() => _payment.CardNumber" Label="Card Number" />
        <MudNeatooTextField For="() => _payment.ExpirationDate" Label="Expiration" />
        <MudNeatooTextField For="() => _payment.CVV" Label="CVV" />
    </MudStep>
</MudStepper>

<MudButton OnClick="Submit"
           Disabled="@(!CanSubmit)">
    Complete Registration
</MudButton>

@code {
    private IPersonalInfoStep _personalInfo = default!;
    private IAddressStep _address = default!;
    private IPaymentStep _payment = default!;

    private bool CanSubmit =>
        _personalInfo.IsValid &&
        _address.IsValid &&
        _payment.IsValid;

    protected override void OnInitialized()
    {
        _personalInfo = PersonalInfoStepFactory.Create();
        _address = AddressStepFactory.Create();
        _payment = PaymentStepFactory.Create();
    }

    private async Task Submit()
    {
        await _personalInfo.WaitForTasks();
        await _address.WaitForTasks();
        await _payment.WaitForTasks();

        if (!CanSubmit) return;

        // Create the real entity from wizard data
        var registration = RegistrationFactory.Create();
        registration.FirstName = _personalInfo.FirstName;
        registration.LastName = _personalInfo.LastName;
        // ... copy other fields
        await RegistrationFactory.Save(registration);
    }
}

Complex Forms

Forms that need rich validation before creating domain entities:

[Factory]
internal partial class OrderEntryForm : ValidateBase<OrderEntryForm>, IOrderEntryForm
{
    public OrderEntryForm(
        IValidateBaseServices<OrderEntryForm> services,
        ICustomerExistsRule customerRule,
        IInventoryCheckRule inventoryRule) : base(services)
    {
        RuleManager.AddRule(customerRule);
        RuleManager.AddRule(inventoryRule);
    }

    [Required]
    public partial string? CustomerNumber { get; set; }

    public partial string? CustomerName { get; set; }  // Populated by rule

    [Required]
    public partial string? ProductCode { get; set; }

    public partial string? ProductName { get; set; }  // Populated by rule

    [Range(1, 1000)]
    public partial int Quantity { get; set; }

    public partial bool InStock { get; set; }  // Set by rule

    public partial int AvailableQuantity { get; set; }  // Set by rule

    [Create]
    public void Create()
    {
        Quantity = 1;
    }
}

// Rule that looks up customer
public class CustomerExistsRule : AsyncRuleBase<OrderEntryForm>, ICustomerExistsRule
{
    private readonly ICustomerService _customerService;

    public CustomerExistsRule(ICustomerService customerService)
        : base(f => f.CustomerNumber)
    {
        _customerService = customerService;
    }

    protected override async Task<IRuleMessages> Execute(
        OrderEntryForm target, CancellationToken? token = null)
    {
        if (string.IsNullOrEmpty(target.CustomerNumber))
        {
            target.CustomerName = null;
            return None;
        }

        var customer = await _customerService.FindByNumberAsync(
            target.CustomerNumber, token ?? CancellationToken.None);

        if (customer == null)
        {
            target.CustomerName = null;
            return (nameof(target.CustomerNumber), "Customer not found")
                .AsRuleMessages();
        }

        target.CustomerName = customer.Name;
        return None;
    }
}

Validation-Only Objects

Objects that validate input without representing persistent domain concepts:

[Factory]
internal partial class PasswordChangeRequest
    : ValidateBase<PasswordChangeRequest>, IPasswordChangeRequest
{
    public PasswordChangeRequest(
        IValidateBaseServices<PasswordChangeRequest> services)
        : base(services)
    {
        // Password match validation
        RuleManager.AddValidation(
            (PasswordChangeRequest r) =>
            {
                if (r.NewPassword != r.ConfirmPassword)
                    return (nameof(r.ConfirmPassword), "Passwords do not match")
                        .AsRuleMessages();
                return RuleMessages.None;
            },
            r => r.NewPassword, r => r.ConfirmPassword);

        // Password strength validation
        RuleManager.AddValidation(
            nameof(NewPassword),
            (PasswordChangeRequest r) =>
            {
                if (string.IsNullOrEmpty(r.NewPassword))
                    return RuleMessage.None;

                if (r.NewPassword.Length < 8)
                    return RuleMessage.Error("Password must be at least 8 characters");

                if (!r.NewPassword.Any(char.IsUpper))
                    return RuleMessage.Error("Password must contain uppercase letter");

                if (!r.NewPassword.Any(char.IsDigit))
                    return RuleMessage.Error("Password must contain a number");

                return RuleMessage.None;
            });
    }

    [Required(ErrorMessage = "Current password is required")]
    public partial string? CurrentPassword { get; set; }

    [Required(ErrorMessage = "New password is required")]
    public partial string? NewPassword { get; set; }

    [Required(ErrorMessage = "Please confirm your password")]
    public partial string? ConfirmPassword { get; set; }

    [Create]
    public void Create() { }
}

Available Meta-Properties

ValidateBase<T> provides these meta-properties (subset of EntityBase<T>):

IsValid

public virtual bool IsValid { get; }

Returns true when this object and all children pass validation.

var form = FormFactory.Create();
form.Email = "invalid";

// form.IsValid == false

IsSelfValid

public virtual bool IsSelfValid { get; }

Returns true when this object’s own properties pass validation, ignoring children.

IsBusy

public virtual bool IsBusy { get; }

Returns true when async validation rules are executing.

IsSelfBusy

public virtual bool IsSelfBusy { get; }

Returns true when this specific object has async operations running.

PropertyMessages

public IReadOnlyCollection<IPropertyMessage> PropertyMessages { get; }

All validation messages for this object.

foreach (var msg in form.PropertyMessages)
{
    Console.WriteLine($"{msg.PropertyName}: {msg.Message}");
}

Parent

public IBase? Parent { get; }

Reference to parent object if this is a child in a hierarchy.

Not Available on ValidateBase

These meta-properties are only available on EntityBase<T>:

  • IsNew - Persistence tracking
  • IsModified - Change tracking
  • IsSelfModified - Change tracking
  • IsDeleted - Deletion tracking
  • IsSavable - Save eligibility
  • IsChild - Child relationship (use Parent != null instead)
  • ModifiedProperties - Changed property list

If you need these, use EntityBase<T> instead.

RuleManager

The RuleManager provides full rule functionality:

AddRule

RuleManager.AddRule(injectedRule);

AddValidation (Inline Sync)

RuleManager.AddValidation(
    nameof(Email),
    (MyForm f) => f.Email?.Contains("@") == true
        ? RuleMessage.None
        : RuleMessage.Error("Invalid email"));

AddValidationAsync (Inline Async)

RuleManager.AddValidationAsync(
    async (MyForm f, CancellationToken ct) =>
    {
        var exists = await _service.CheckExists(f.Value, ct);
        return exists
            ? (nameof(f.Value), "Already exists").AsRuleMessages()
            : RuleMessages.None;
    },
    f => f.Value);

AddAction (Transformation)

RuleManager.AddAction(
    (MyForm f) => f.FullName = $"{f.FirstName} {f.LastName}",
    f => f.FirstName, f => f.LastName);

AddActionAsync

RuleManager.AddActionAsync(
    async (MyForm f, CancellationToken ct) =>
    {
        f.Details = await _service.LookupDetails(f.Code, ct);
    },
    f => f.Code);

Integration with UI Binding

ValidateBase<T> implements INotifyPropertyChanged, enabling seamless Blazor binding:

<EditForm Model="@_form">
    <MudNeatooTextField For="() => _form.Email" Label="Email" />

    @if (!_form.IsValid)
    {
        <MudAlert Severity="Severity.Error">
            @foreach (var msg in _form.PropertyMessages)
            {
                <p>@msg.Message</p>
            }
        </MudAlert>
    }

    <MudButton OnClick="Submit"
               Disabled="@(!_form.IsValid || _form.IsBusy)">
        Submit
    </MudButton>
</EditForm>

@code {
    private IMyForm _form = default!;

    protected override void OnInitialized()
    {
        _form = MyFormFactory.Create();
    }

    private async Task Submit()
    {
        await _form.WaitForTasks();
        if (_form.IsValid)
        {
            // Process the validated form
        }
    }
}

Factory Support

ValidateBase<T> classes support the [Factory] attribute and factory methods:

[Factory]
internal partial class SearchForm : ValidateBase<SearchForm>, ISearchForm
{
    // ...

    [Create]
    public void Create()
    {
        // Initialize defaults
        StartDate = DateTime.Today.AddMonths(-1);
        EndDate = DateTime.Today;
    }
}

However, [Insert], [Update], and [Delete] are typically not used since ValidateBase<T> objects are not persisted directly. The Save() method is not available.

Using Factory

// Create a new form
var form = SearchFormFactory.Create();

// No Save() method - forms are not persisted
// Instead, use the validated data to create/update real entities

Complete Example: Search Form

public interface IProductSearchForm : IValidateBase
{
    string? SearchTerm { get; set; }
    DateTime? StartDate { get; set; }
    DateTime? EndDate { get; set; }
    decimal? MinPrice { get; set; }
    decimal? MaxPrice { get; set; }
    ProductCategory? Category { get; set; }
    bool InStockOnly { get; set; }
}

[Factory]
internal partial class ProductSearchForm
    : ValidateBase<ProductSearchForm>, IProductSearchForm
{
    public ProductSearchForm(
        IValidateBaseServices<ProductSearchForm> services) : base(services)
    {
        // Date range validation
        RuleManager.AddValidation(
            (ProductSearchForm f) =>
            {
                if (f.StartDate.HasValue && f.EndDate.HasValue
                    && f.StartDate > f.EndDate)
                {
                    return (nameof(f.EndDate), "End date must be after start date")
                        .AsRuleMessages();
                }
                return RuleMessages.None;
            },
            f => f.StartDate, f => f.EndDate);

        // Price range validation
        RuleManager.AddValidation(
            (ProductSearchForm f) =>
            {
                if (f.MinPrice.HasValue && f.MaxPrice.HasValue
                    && f.MinPrice > f.MaxPrice)
                {
                    return (nameof(f.MaxPrice), "Max price must be >= min price")
                        .AsRuleMessages();
                }
                return RuleMessages.None;
            },
            f => f.MinPrice, f => f.MaxPrice);
    }

    [StringLength(100)]
    public partial string? SearchTerm { get; set; }

    public partial DateTime? StartDate { get; set; }

    public partial DateTime? EndDate { get; set; }

    [Range(0, double.MaxValue, ErrorMessage = "Price must be positive")]
    public partial decimal? MinPrice { get; set; }

    [Range(0, double.MaxValue, ErrorMessage = "Price must be positive")]
    public partial decimal? MaxPrice { get; set; }

    public partial ProductCategory? Category { get; set; }

    public partial bool InStockOnly { get; set; }

    [Create]
    public void Create()
    {
        InStockOnly = false;
    }
}

Usage in Blazor:

@page "/products/search"

<MudPaper Class="pa-4">
    <MudText Typo="Typo.h5">Search Products</MudText>

    <MudGrid>
        <MudItem xs="12">
            <MudNeatooTextField For="() => _search.SearchTerm"
                                Label="Search"
                                Adornment="Adornment.Start"
                                AdornmentIcon="@Icons.Material.Filled.Search" />
        </MudItem>

        <MudItem xs="6">
            <MudNeatooDatePicker For="() => _search.StartDate"
                                 Label="From Date" />
        </MudItem>

        <MudItem xs="6">
            <MudNeatooDatePicker For="() => _search.EndDate"
                                 Label="To Date" />
        </MudItem>

        <MudItem xs="6">
            <MudNeatooNumericField For="() => _search.MinPrice"
                                   Label="Min Price"
                                   Format="C2" />
        </MudItem>

        <MudItem xs="6">
            <MudNeatooNumericField For="() => _search.MaxPrice"
                                   Label="Max Price"
                                   Format="C2" />
        </MudItem>

        <MudItem xs="6">
            <MudNeatooSelect For="() => _search.Category"
                             Label="Category">
                <MudSelectItem Value="@((ProductCategory?)null)">All</MudSelectItem>
                @foreach (var cat in Enum.GetValues<ProductCategory>())
                {
                    <MudSelectItem Value="@((ProductCategory?)cat)">@cat</MudSelectItem>
                }
            </MudNeatooSelect>
        </MudItem>

        <MudItem xs="6">
            <MudNeatooCheckBox For="() => _search.InStockOnly"
                               Label="In Stock Only" />
        </MudItem>
    </MudGrid>

    <MudButton OnClick="Search"
               Disabled="@(!_search.IsValid)"
               Color="Color.Primary"
               Variant="Variant.Filled"
               Class="mt-4">
        Search
    </MudButton>
</MudPaper>

@if (_results != null)
{
    <MudTable Items="_results" Class="mt-4">
        @* Results table *@
    </MudTable>
}

@code {
    private IProductSearchForm _search = default!;
    private List<Product>? _results;

    protected override void OnInitialized()
    {
        _search = ProductSearchFormFactory.Create();
    }

    private async Task Search()
    {
        await _search.WaitForTasks();

        if (!_search.IsValid) return;

        _results = await ProductService.SearchAsync(
            _search.SearchTerm,
            _search.StartDate,
            _search.EndDate,
            _search.MinPrice,
            _search.MaxPrice,
            _search.Category,
            _search.InStockOnly);
    }
}