Custom Validation — IValidatableObject and Custom Attributes

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) });
    }
}
Note: 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.
Tip: The second parameter to 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.
Warning: Do not call external services (database, HTTP APIs) inside 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.

🧠 Test Yourself

A ViewModel implements IValidatableObject with a cross-property check. [Required] on StartDate fails. Does IValidatableObject.Validate() run?