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

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

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.