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.

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.DisplayNamegives 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
IValidatableObjecton the model instead. ItsValidatemethod 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 viaValidationContext.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.
Comments (0)
No comments yet — be the first to share your thoughts.