All articles

Custom Validation with ValidationAttribute in ASP.NET Core MVC

Build a reusable [FutureDate] rule with ValidationAttribute, add client-side checks via IClientModelValidator, and know when IValidatableObject fits better.

0 · log in to like, save & follow Share on LinkedIn Share on X
Custom Validation with ValidationAttribute in ASP.NET Core MVC

Data annotations like [Required] and [StringLength] cover the basics, but real rules are domain rules: a travel date can't be in the past, a discount can't exceed the order total, a username must not be reserved. In ASP.NET Core MVC you express those by deriving from ValidationAttribute — and, if you want instant feedback in the browser, by implementing IClientModelValidator alongside it.

Custom Validation with ValidationAttribute in ASP.NET Core MVC

This post builds a complete [FutureDate] attribute for .NET 10: server rule, custom error messages, an attribute parameter, and unobtrusive client-side validation. Everything is verified against the companion repository.

The scenario

A booking form where the travel date must be today or later, and at most six months ahead:

public class BookingForm
{
    [Required, StringLength(60)]
    public string CustomerName { get; set; } = "";

    [Required, DataType(DataType.Date)]
    [FutureDate(MaxMonthsAhead = 6, ErrorMessage = "Travel date must be today or later.")]
    public DateTime? TravelDate { get; set; }
}

Writing the ValidationAttribute

public class FutureDateAttribute : ValidationAttribute, IClientModelValidator
{
    public int MaxMonthsAhead { get; set; } = 12;

    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is not DateTime date) return ValidationResult.Success; // [Required] handles empties

        if (date.Date < DateTime.Today)
            return new ValidationResult(ErrorMessage ?? $"{context.DisplayName} cannot be in the past.");

        if (date.Date > DateTime.Today.AddMonths(MaxMonthsAhead))
            return new ValidationResult($"{context.DisplayName} cannot be more than {MaxMonthsAhead} months ahead.");

        return ValidationResult.Success;
    }
}

Design decisions worth stealing:

  • Return success for null/wrong type. Each attribute should validate one thing; let [Required] own emptiness. Stacking small attributes composes better than one mega-validator.
  • ValidationContext.DisplayName gives you the property's display name for free, so default messages read naturally ("Travel date cannot be in the past").
  • Attribute properties (MaxMonthsAhead) make the rule reusable across models with different limits.

The framework runs this during model binding — by the time your action executes, ModelState already contains the verdict:

[HttpPost, ValidateAntiForgeryToken]
public IActionResult Index(BookingForm form)
{
    if (!ModelState.IsValid) return View(form);
    TempData["Saved"] = $"Booked {form.CustomerName} for {form.TravelDate:yyyy-MM-dd}";
    return RedirectToAction(nameof(Index));
}

Verified behavior — posting a past date re-renders the form with the message; a valid date redirects (Post/Redirect/Get):

POST /Validation  TravelDate=2020-01-01   → 200, page shows "Travel date must be today or later."
POST /Validation  TravelDate=+10 days     → 302 Redirect (saved)

Adding client-side validation

Server validation is the source of truth, but a round trip per mistake is poor UX. IClientModelValidator lets the attribute emit data-* attributes that jQuery unobtrusive validation picks up:

public void AddValidation(ClientModelValidationContext context)
{
    context.Attributes.TryAdd("data-val", "true");
    context.Attributes.TryAdd("data-val-futuredate", ErrorMessage ?? "Pick today or a future date.");
    context.Attributes.TryAdd("data-val-futuredate-maxmonths", MaxMonthsAhead.ToString());
}

The rendered input now carries the rule and its parameter:

<input data-val="true" data-val-futuredate="Travel date must be today or later."
       data-val-futuredate-maxmonths="6" type="date" id="TravelDate" name="TravelDate" />

On the client, register a matching validator and adapter (after _ValidationScriptsPartial):

<script>
    $.validator.addMethod('futuredate', function (value, element, params) {
        if (!value) return true;
        const picked = new Date(value); const today = new Date(); today.setHours(0,0,0,0);
        const max = new Date(today); max.setMonth(max.getMonth() + parseInt(params));
        return picked >= today && picked <= max;
    });
    $.validator.unobtrusive.adapters.add('futuredate', ['maxmonths'], function (options) {
        options.rules['futuredate'] = options.params.maxmonths;
        options.messages['futuredate'] = options.message;
    });
</script>

The naming contract: data-val-futuredate maps to the validator name, and data-val-futuredate-maxmonths becomes options.params.maxmonths. Keep the client rule a mirror of the server rule — never a replacement. Anyone can post directly past your JavaScript.

When an attribute isn't enough

Two situations call for different tools:

  • The rule needs other properties of the model — implement IValidatableObject on the model instead. Its Validate method sees the whole object, so "discount must not exceed total" is a three-liner there.
  • The rule needs services or a database (unique username, stock check) — validate in the action or a handler, and add failures with ModelState.AddModelError("Username", "Already taken."). Attributes can reach services via ValidationContext.GetService, but rules with I/O usually belong in your application layer, where they can be async and properly tested.

For cross-cutting patterns (phone numbers, postal codes), a small [RegularExpression] subclass with a fixed pattern and message keeps models tidy:

public class IndianPincodeAttribute() : RegularExpressionAttribute(@"^[1-9]\d{5}$")
{
    public override string FormatErrorMessage(string name) => $"{name} must be a 6-digit PIN code.";
}

Summary

ValidationAttribute gives you reusable, declarative domain rules that plug into model binding; IClientModelValidator extends the same rule to the browser with a few data-* attributes and a matching jQuery adapter. Validate one thing per attribute, mirror — don't replace — the server rule on the client, and reach for IValidatableObject or action-level checks when rules span properties or need I/O. The full working form is in the companion repository.

Enjoyed this article? Get the best GeeksArray articles in your inbox — once a week, no spam, unsubscribe anytime.

Comments (0)

Log in to join the conversation.

No comments yet — be the first to share your thoughts.