DataAnnotations attributes put validation rules right on the model — [Required], [StringLength], [Range] — and ASP.NET Core enforces them automatically during model binding. This tutorial builds a validated model, shows the exact error responses a real API returned, and covers server-side re-display, client-side validation, and writing a custom attribute.

The validated model
using System.ComponentModel.DataAnnotations;
public class Supplier
{
public int Id { get; set; }
[Required(ErrorMessage = "Supplier name is required")]
[StringLength(50, MinimumLength = 3)]
public string Name { get; set; } = string.Empty;
[EmailAddress]
public string? ContactEmail { get; set; }
[Range(1, 5, ErrorMessage = "Rating must be between 1 and 5")]
public int Rating { get; set; }
[RegularExpression(@"^[A-Z]{2}-\d{4}$", ErrorMessage = "Code looks like XX-0000")]
public string? Code { get; set; }
[Url]
public string? Website { get; set; }
}
The attributes you'll use most:
[Required]— value must be present. With nullable reference types, a non-nullablestringis implicitly required in APIs; the attribute makes it explicit and sets the message.[StringLength(50, MinimumLength = 3)]— length window; also sizes the database column when the model doubles as an EF Core entity.[Range(1, 5)]— numeric bounds.[EmailAddress],[Url],[Phone]— format checks for the common string shapes.[RegularExpression]— anything else with a pattern.[Compare(nameof(Password))]— two properties must match (confirm-password).
What the API actually returns
With [ApiController], validation runs after model binding and invalid requests never reach your action. Posting {"name":"x","rating":9} to the verified suppliers API:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["The field Name must be a string with a minimum length of 3 and a maximum length of 50."],
"Rating": ["Rating must be between 1 and 5"]
}
}
A standardized 400 with application/problem+json and per-field messages — no code written. A valid post returned 201 Created with the entity.
MVC pages: ModelState and re-display
Page controllers don't auto-reject; you check ModelState and re-render with errors:
[HttpPost]
public IActionResult Create(Supplier supplier)
{
if (!ModelState.IsValid)
return View(supplier); // redisplay the form with messages
// save…
return RedirectToAction("Index");
}
The Razor side renders messages next to fields:
<form asp-action="Create" method="post">
<label asp-for="Name"></label>
<input asp-for="Name" />
<span asp-validation-for="Name" class="text-danger"></span>
<input asp-for="Rating" />
<span asp-validation-for="Rating" class="text-danger"></span>
<button type="submit">Save</button>
</form>
asp-validation-for shows the exact message from the attribute — the same text the API version put in errors.
Client-side validation
The same attributes emit data-val-* markup, and the jQuery-validation scripts (included in the template's _ValidationScriptsPartial) enforce them in the browser before the request is sent:
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
Client-side validation is a courtesy, not a defense — it saves a round trip for typos. The server-side check always runs; never remove it because the browser "already validated."
Custom validation attributes
When no built-in attribute fits, derive one:
public class NotWeekendAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is DateTime date &&
date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
return new ValidationResult("Delivery date cannot fall on a weekend.");
return ValidationResult.Success;
}
}
[NotWeekend]
public DateTime DeliveryDate { get; set; }
For rules that need services or several properties at once, implement IValidatableObject on the model — its Validate method runs after the attribute checks pass.
Comparing properties and conditional rules
[Compare] checks two properties match — the confirm-password classic:
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
[Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty;
For rules that depend on other values ("Discount required only when IsOnSale"), attributes run out of road — implement IValidatableObject on the model:
public class Promotion : IValidatableObject
{
public bool IsOnSale { get; set; }
public decimal? DiscountPercent { get; set; }
public DateTime StartsOn { get; set; }
public DateTime EndsOn { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext _)
{
if (IsOnSale && DiscountPercent is null)
yield return new ValidationResult(
"Discount is required for sale items", [nameof(DiscountPercent)]);
if (EndsOn <= StartsOn)
yield return new ValidationResult(
"End date must be after the start date", [nameof(EndsOn)]);
}
}
Validate runs only after all attribute checks pass, and its results land in the same ModelState/problem response. Naming the members ties each message to the right field in the UI.
Validating collections and nested objects
Validation walks object graphs automatically — items in a List<OrderLine> each get their attribute checks, and errors come back indexed:
"errors": {
"Lines[1].Quantity": ["Quantity must be between 1 and 100"]
}
That indexed key is exactly what asp-validation-for="Lines[1].Quantity" renders against, so editable lists get per-row messages for free.
Validating outside MVC
The same attributes work anywhere via Validator — useful in console apps, background jobs, or unit tests of the rules themselves:
var supplier = new Supplier { Name = "x", Rating = 9 };
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(
supplier, new ValidationContext(supplier), results, validateAllProperties: true);
// isValid == false; results carries both messages
validateAllProperties: true matters — without it only [Required] runs.
Common pitfalls
[Required]on non-nullable value types does nothing — anintcan't be missing; it binds as0. Make itint?if "not supplied" must fail validation.- Client scripts missing — attributes emit
data-val-*only when the_ValidationScriptsPartialis on the page; without it users get server round-trips, not broken validation. - Validating entities instead of requests — the rules for "acceptable API input" and "valid database row" drift apart; keep DTO validation separate from persistence.
Guidance
- Validate on request models/DTOs, not EF entities — the API's rules ("required at creation") and the database's rules often differ.
- Put human messages on every attribute that users will see; the defaults are serviceable but robotic.
- Keep controller actions clean: in APIs let
[ApiController]reject; in pages theModelState.IsValid+ redisplay pattern is all you need.
Comments (0)
No comments yet — be the first to share your thoughts.