Skip to content

DataAnnotations & IValidatableObject

The schema generator already reads the attributes it can turn into field rules, so [Required] and [Range] need nothing extra. What no attribute can express is a rule that reads more than one property — check out after check in, guests within the room's capacity — and in .NET that is IValidatableObject. UseDataAnnotations() runs it, exactly as UseFluentValidation() runs a FluentValidation validator — and the two compose, so a model that carries both gets both. The rule about single spaces in the guest's name is a FluentValidation rule sitting alongside the model's own; a complaint the two layers would word identically on the same field is still read once.

Live form
Room booking

Room type

Try more guests than the room sleeps.

View source
public class RoomBooking : IValidatableObject
{
    [Required, Display(Name = "Guest name")] public string GuestName { get; set; } = "";
    [Required, EmailAddress]                 public string Email     { get; set; } = "";
    public DateOnly CheckIn  { get; set; }
    public DateOnly CheckOut { get; set; }
    [Range(1, 8)] public int Guests { get; set; } = 1;
    public string RoomType { get; set; } = "single";

    // The rules that need more than one property — which is precisely what an attribute cannot see.
    public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
    {
        if (CheckOut <= CheckIn)
            yield return new("Check out must be after check in.", [nameof(CheckOut)]);

        var capacity = RoomType switch { "single" => 1, "double" => 2, _ => 4 };
        if (Guests > capacity)
            // Naming two members puts the same complaint under both, so whichever one the user is
            // looking at explains itself.
            yield return new($"A {RoomType} room sleeps {capacity}.", [nameof(Guests), nameof(RoomType)]);

        // Naming no member at all is a statement about the booking as a whole, and lands on the form.
        if ((CheckOut.DayNumber - CheckIn.DayNumber) > 28)
            yield return new("Stays longer than 28 nights have to be booked by phone.");
    }
}

// The attributes are already field rules through the generator, so only the cross-property layer is
// added — running Validator over the properties again would put two differently-worded copies of
// "required" under the same box. Pass includePropertyAttributes: true when the schema came from
// somewhere that never saw them (a JSON Schema document rendered over a typed model).
var state = new BormState(BormSchemaGenerator.Generate<RoomBooking>(),
                          new BormModelDataAccessor(model))
    // The two integrations combine rather than replace each other, so a model whose rules are split
    // across both layers gets both. Either order works.
    .UseDataAnnotations()
    .UseFluentValidation(new RoomBookingValidator());

public sealed class RoomBookingValidator : AbstractValidator<RoomBooking>
{
    public RoomBookingValidator()
        => RuleFor(x => x.GuestName)
            .Must(name => !name.Contains("  "))
            .WithMessage("Please use single spaces in the guest's name.");
}
An unhandled error has occurred. Reload 🗙