Data Validation in ASP.NET
A practical, copy-ready guide to model validation for APIs, MVC, Minimal APIs, and Razor views. Includes data annotations, custom validators, FluentValidation, and response patterns.
Why Validate?
Validation ensures incoming data is complete, well-formed, and within acceptable ranges before your app processes it. In ASP.NET, validation typically happens on the model (DTOs/ViewModels) so your controllers and endpoints can rely on a known-good shape. You can combine server-side validation (authoritative) with client-side validation (fast feedback).
Data Annotations (Built-in)
Start with attributes like Required, StringLength, Range, EmailAddress, and RegularExpression. They are simple, expressive, and work across MVC, API controllers, and Minimal APIs (when you validate manually).
using System.ComponentModel.DataAnnotations;
public class RegisterUserDto
{
[Required]
[StringLength(50, MinimumLength = 3)]
public string Username { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required]
[DataType(DataType.Password)]
[StringLength(100, MinimumLength = 8, ErrorMessage = "Password must be at least 8 chars.")]
public string Password { get; set; } = string.Empty;
[Range(18, 120)]
public int Age { get; set; }
}Validating in Controllers
API controllers annotated with [ApiController] automatically validate bound models and return 400with ProblemDetails. You can also check ModelState.IsValid explicitly for custom flows.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
[HttpPost("register")]
public IActionResult Register([FromBody] RegisterUserDto dto)
{
if (!ModelState.IsValid)
{
// Returns RFC 7807 ProblemDetails with errors by default in ASP.NET Core APIs.
return ValidationProblem(ModelState);
}
// ... create user
return Created(string.Empty, new { message = "Registered" });
}
}{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": [ "The Email field is not a valid e-mail address." ],
"Password": [ "Password must be at least 8 chars." ]
},
"traceId": "00-..."
}Custom Validators
When built-ins are not enough, create a custom ValidationAttribute. This is great for business rules like blocked emails, blacklisted values, or lookups.
using System.ComponentModel.DataAnnotations;
public class NotBannedEmailAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var email = value as string;
if (string.IsNullOrWhiteSpace(email)) return ValidationResult.Success;
var banned = new[] { "test@spam.com", "fake@spam.com" };
return banned.Contains(email, StringComparer.OrdinalIgnoreCase)
? new ValidationResult("This email address is not allowed.")
: ValidationResult.Success;
}
}
public class NewsletterSignupDto
{
[Required, EmailAddress, NotBannedEmail]
public string Email { get; set; } = string.Empty;
}IValidatableObject (Cross-Property)
Use IValidatableObject for validations that involve multiple properties (e.g., start & end dates). It emitsValidationResult entries with affected member names so UI can highlight multiple fields at once.
using System.ComponentModel.DataAnnotations;
public class DateRangeDto : IValidatableObject
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (End < Start)
{
yield return new ValidationResult("'End' must be on or after 'Start'.",
new[] { nameof(End), nameof(Start) });
}
}
}Client-Side Validation in MVC/Razor
MVC and Razor Pages can use unobtrusive validation to mirror server rules in the browser. Include the_ValidationScriptsPartial and use asp-for / asp-validation-for tag helpers.
@model RegisterUserDto
<form asp-action="Register" method="post">
<div class="form-group">
<label asp-for="Username"></label>
<input asp-for="Username" class="form-control" />
<span asp-validation-for="Username" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Password"></label>
<input asp-for="Password" type="password" class="form-control" />
<span asp-validation-for="Password" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}Shaping API Error Responses
ASP.NET Core emits standardized ProblemDetails for invalid models. If you need to change the status code or payload shape, you can override the API behavior options.
using Microsoft.AspNetCore.Mvc;
builder.Services.AddControllers().ConfigureApiBehaviorOptions(options =>
{
// Change default 400 to 422 for validation errors, for example
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Status = StatusCodes.Status422UnprocessableEntity,
Title = "Validation failed."
};
return new UnprocessableEntityObjectResult(problemDetails);
};
});FluentValidation
For complex business rules, a fluent rules engine can be more readable and testable than attributes. FluentValidation is a popular choice and integrates well with both MVC and Minimal APIs.
using FluentValidation;
public class OrderDto
{
public string ProductId { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class OrderValidator : AbstractValidator<OrderDto>
{
public OrderValidator()
{
RuleFor(x => x.ProductId).NotEmpty().Length(3, 32);
RuleFor(x => x.Quantity).GreaterThan(0);
RuleFor(x => x.UnitPrice).GreaterThanOrEqualTo(0);
RuleFor(x => x.Quantity * x.UnitPrice).LessThanOrEqualTo(10000)
.WithMessage("Order total must be ≤ 10,000.");
}
}Minimal APIs Example
Minimal APIs do not auto-validate models; invoke your validator and return a ValidationProblem with grouped errors when needed.
using FluentValidation;
using Microsoft.AspNetCore.Builder;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<OrderValidator>();
var app = builder.Build();
app.MapPost("/orders", (OrderDto dto, IValidator<OrderDto> validator) =>
{
var result = validator.Validate(dto);
if (!result.IsValid)
{
var errors = result.ToDictionary(); // extension that groups errors by property
return Results.ValidationProblem(errors);
}
return Results.Created($"/orders/123", dto);
});
app.Run();Manual Validation (Validator API)
Outside of MVC (or inside tests), you can use Validator.TryValidateObject to run data-annotation rules.
using System.ComponentModel.DataAnnotations;
var dto = new RegisterUserDto { Username = "ab", Email = "bad-email", Password = "123", Age = 10 };
var ctx = new ValidationContext(dto);
var results = new List<ValidationResult>();
var isValid = Validator.TryValidateObject(dto, ctx, results, validateAllProperties: true);
foreach (var err in results)
{
Console.WriteLine($"{err.ErrorMessage} (Members: {string.Join(", ", err.MemberNames)})");
}Route Constraints & Escaping
Attribute routing often includes braces, e.g., {"{"}id:int"}". When documenting such routes inside TSX/JSX, render them from strings (like we do here) to avoid the JSX parser treating braces as expression delimiters.
// Example shows escaping braces often found in attribute routing
[HttpGet("users/{id:int}")]
public IActionResult GetUser(int id) => Ok(new { id });Tips & Best Practices
- Validate at the edges (DTOs/ViewModels) and keep domain entities clean when possible.
- Return machine-readable errors (ProblemDetails) to help clients map messages to fields.
- Favor specific rules over generic regexes; provide clear messages for each rule.
- Use
IValidatableObjector a fluent library for cross-field rules. - Mirror server rules on the client for better UX, but never trust client-only validation.
- Log validation failures at appropriate levels to spot abuse and UX issues.