DataAnnotations cover the common validation rules, but many business rules require cross-property validation or custom logic: “the end date must be after the start date,” “the slug must be unique in the database,” “if category is ‘Premium’ then price is required.” Two mechanisms handle this: IValidatableObject adds cross-property validation directly to the ViewModel (runs automatically as part of model binding), and custom ValidationAttribute subclasses create reusable attributes for business rules that apply across multiple ViewModels.
IValidatableObject — Cross-Property Validation
public class CreateEventViewModel : IValidatableObject
{
[Required]
[Display(Name = "Event Name")]
public string Name { get; set; } = string.Empty;
[Required]
[DataType(DataType.Date)]
[Display(Name = "Start Date")]
public DateTime StartDate { get; set; }
[Required]
[DataType(DataType.Date)]
[Display(Name = "End Date")]
public DateTime EndDate { get; set; }
[Range(0, 10000)]
public int? MaxAttendees { get; set; }
public bool RequiresRegistration { get; set; }
// IValidatableObject.Validate — runs AFTER DataAnnotations succeed
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
// Cross-property validation: end must be after start
if (EndDate <= StartDate)
yield return new ValidationResult(
"End date must be after start date.",
new[] { nameof(EndDate) }); // associates error with EndDate field
// Conditional requirement: registration requires a capacity
if (RequiresRegistration && MaxAttendees is null)
yield return new ValidationResult(
"Maximum attendees is required when registration is enabled.",
new[] { nameof(MaxAttendees) });
// Future date check
if (StartDate.Date < DateTime.UtcNow.Date)
yield return new ValidationResult(
"Start date must be in the future.",
new[] { nameof(StartDate) });
}
}
IValidatableObject.Validate() runs after all DataAnnotations pass. If any DataAnnotation fails (Required, StringLength, etc.), Validate() is not called. This is by design — cross-property validation only makes sense when individual fields are structurally valid. If StartDate is null because [Required] failed, there is no point checking if EndDate is after StartDate. The waterfall order (annotations first, then Validate()) prevents confusing compound errors.ValidationResult is a collection of member names — property names that the error should be associated with. new[] { nameof(EndDate) } attaches the error to the EndDate field, so asp-validation-for="EndDate" in the view will display it next to the field. Omitting the member names (or using null) creates a model-level error that appears in asp-validation-summary but not next to any specific field. Use both: field-level errors for field-specific issues, model-level errors for general business rule violations.IValidatableObject.Validate() or ValidationAttribute.IsValid(). These methods are synchronous — they run synchronously on a thread pool thread. Database calls are async; calling them synchronously inside validation can cause deadlocks in ASP.NET Core. For async validation (uniqueness checks, external service validation), perform these in the action method after ModelState.IsValid passes, and add errors with ModelState.AddModelError().Custom ValidationAttribute
// ── Reusable custom validation attribute ──────────────────────────────────
public sealed class SlugFormatAttribute : ValidationAttribute
{
private static readonly Regex SlugRegex = SlugRegexGenerator();
public SlugFormatAttribute()
: base("The {0} field must contain only lowercase letters, numbers, and hyphens.") { }
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is null) return ValidationResult.Success; // [Required] handles null
var slug = value.ToString()!;
if (!SlugRegex.IsMatch(slug))
return new ValidationResult(
FormatErrorMessage(context.DisplayName),
new[] { context.MemberName! });
return ValidationResult.Success;
}
[GeneratedRegex(@"^[a-z0-9-]+$")]
private static partial Regex SlugRegexGenerator();
}
// ── Usage — reusable across multiple ViewModels ──────────────────────────
public class CreatePostViewModel
{
[Required]
[StringLength(100, MinimumLength = 3)]
[SlugFormat] // reusable custom attribute
public string Slug { get; set; } = string.Empty;
}
public class CreateCategoryViewModel
{
[Required]
[StringLength(50)]
[SlugFormat] // same attribute on a different ViewModel
public string Slug { get; set; } = string.Empty;
}
Adding Errors Manually in the Action
// ── Async validation — uniqueness check after ModelState passes ────────────
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePostViewModel model)
{
if (!ModelState.IsValid) return View(model);
// Async uniqueness check — cannot do this in DataAnnotations (synchronous)
if (await _service.SlugExistsAsync(model.Slug))
{
ModelState.AddModelError(nameof(model.Slug),
"This slug is already in use. Please choose a different one.");
return View(model);
}
// Model-level error (not attached to a specific field)
if (!await _service.CanUserCreatePostAsync(User.GetUserId()))
{
ModelState.AddModelError(string.Empty, // empty key = model-level
"You have reached the maximum number of posts for your account.");
return View(model);
}
await _service.CreateAsync(model.ToRequest(), User.GetUserId());
TempData["Success"] = "Post created successfully!";
return RedirectToAction(nameof(Index));
}
Common Mistakes
Mistake 1 — Calling database inside IsValid or Validate (async-in-sync deadlock)
❌ Wrong — synchronous database call inside async context:
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
var service = (IPostService)ctx.GetService(typeof(IPostService))!;
if (service.SlugExistsAsync(slug).Result) ... // .Result deadlock risk!
✅ Correct — do async validation in the action method with ModelState.AddModelError().
Mistake 2 — IValidatableObject running before DataAnnotations complete
❌ Wrong assumption — IValidatableObject runs even when individual fields have DataAnnotation errors.
✅ Correct understanding — Validate() runs only when ALL DataAnnotations have passed; design accordingly.