Isaac.

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).

DTO with data annotations
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.

Controller returning ProblemDetails on validation failure
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" });
    }
}
Example ProblemDetails payload
{
  "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.

Custom ValidationAttribute
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.

Cross-property validation with IValidatableObject
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.

Razor view with client-side validation
@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.

Configure API behavior for validation responses
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.

Model & validator
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.

Minimal API endpoint with FluentValidation
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.

Manual validation using Validator API
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.

Attribute route with braces
// 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 IValidatableObject or 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.