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.
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.");
}