All articles

How to Validate MVC Models using DataAnnotation Attributes

Model validation with DataAnnotations in ASP.NET Core — the attributes that matter, verified 400 problem responses, ModelState redisplay, client-side validation, and custom attributes.

0 · log in to like, save & follow Share on LinkedIn Share on X

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.

cover

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-nullable string is 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 — an int can't be missing; it binds as 0. Make it int? if "not supplied" must fail validation.
  • Client scripts missing — attributes emit data-val-* only when the _ValidationScriptsPartial is 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 the ModelState.IsValid + redisplay pattern is all you need.
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.