Validating Input
Bad data reached your database and corrupted state
Use Case: Validating Input
The everyday problem: Data arrives from outside your system — API request bodies, form submissions, query parameters, file contents, messages — and none of it can be trusted. An email field contains "not-an-email", a quantity is negative, a required field is missing, a string is 10,000 characters where 100 was expected, a date is in the wrong format. If bad data reaches your business logic or database, you get corrupted state, crashes, security holes, and confusing errors deep in the stack where the real cause (bad input at the edge) is invisible. The challenge is rejecting invalid input cleanly and early — at the boundary, with clear error messages — so the rest of your code can trust the data it works with.
Five approaches, from declarative attributes to rich domain validation. They are not mutually exclusive — most applications layer several, validating at different depths for different purposes.
Way 1: Data Annotations (declarative attributes)
Best used for: Simple, common validation rules expressed directly on the model — required fields, string lengths, ranges, formats — where the rule is a property of the data shape and built-in attributes cover it.
The concept: You decorate model properties with validation attributes ([Required], [Range], [EmailAddress], [StringLength]). ASP.NET Core automatically validates the model during model binding and populates ModelState with any errors before your action runs — declarative, built-in, zero configuration.
public class CreateProductRequest
{
[Required(ErrorMessage = "Name is required.")]
[StringLength(100, MinimumLength = 3)]
public string Name { get; set; } = "";
[Range(0.01, 1_000_000, ErrorMessage = "Price must be between 0.01 and 1,000,000.")]
public decimal Price { get; set; }
[Required, EmailAddress]
public string ContactEmail { get; set; } = "";
[RegularExpression(@"^[A-Z]{2}\d{4}$", ErrorMessage = "SKU must be 2 letters + 4 digits.")]
public string Sku { get; set; } = "";
[Range(0, int.MaxValue)]
public int StockQuantity { get; set; }
}
// In a controller, [ApiController] auto-returns 400 with errors before the action runs.
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
// If we reach here, the model already passed annotation validation.
// [ApiController] returned 400 ProblemDetails automatically otherwise.
return Ok();
}
}
Why it works: The rules live right on the model as declarative metadata, and the framework enforces them automatically during binding — no validation code to write or call. With [ApiController], invalid requests are rejected with a standardised 400 problem-details response before your action executes, so your action only ever sees structurally valid data.
Benchmark note: Data annotations are the lowest-friction validation — built in, declarative, and automatically wired into the request pipeline. They cover the common cases (required, length, range, format) cleanly. Their limits: they handle property-level rules well but struggle with cross-property rules ("end date must be after start date"), conditional rules ("phone required only if contactByPhone is true"), and rules needing external data ("email must not already exist" — requires a database check). Complex logic crammed into custom ValidationAttribute classes becomes awkward. Use data annotations for the straightforward majority of rules; reach for FluentValidation (Way 2) when rules get complex or need dependencies. They also serve as documentation and can drive client-side validation and OpenAPI schema constraints.
Way 2: FluentValidation (expressive, testable rules)
Best used for: Complex validation — cross-property rules, conditional logic, rules requiring injected dependencies (database, services), and anywhere you want validation logic separated from the model and unit-testable.
The concept: FluentValidation defines rules in a dedicated validator class using a fluent API, separate from the model. Rules can express conditions, cross-property comparisons, custom logic, and — because validators support dependency injection — checks against databases or services. The validation lives in testable classes rather than attributes.
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
private readonly IProductRepository _repo;
public CreateProductValidator(IProductRepository repo) // DI — can check the database
{
_repo = repo;
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required.")
.Length(3, 100);
RuleFor(x => x.Price)
.GreaterThan(0).LessThanOrEqualTo(1_000_000);
RuleFor(x => x.ContactEmail)
.NotEmpty().EmailAddress();
// Cross-property / conditional rule — awkward with annotations, natural here
RuleFor(x => x.SalePrice)
.LessThan(x => x.Price)
.When(x => x.SalePrice.HasValue)
.WithMessage("Sale price must be below the regular price.");
// Async rule using an injected dependency — impossible with plain annotations
RuleFor(x => x.Sku)
.MustAsync(async (sku, ct) => !await _repo.SkuExistsAsync(sku, ct))
.WithMessage("SKU already exists.");
}
}
// Register
builder.Services.AddScoped<IValidator<CreateProductRequest>, CreateProductValidator>();
// Invoke (manually, or via auto-validation integration)
public async Task<IActionResult> Create(
CreateProductRequest request, IValidator<CreateProductRequest> validator, CancellationToken ct)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
return ValidationProblem(result.ToDictionary()); // 400 with structured errors
return Ok();
}
Why it works: Separating validation into dedicated classes makes complex rules readable and, crucially, unit-testable in isolation — you can test the validator without spinning up the web stack. Dependency injection unlocks validation that needs external data (uniqueness checks, reference-data validation), and the fluent API expresses conditional and cross-property rules far more naturally than attributes.
Benchmark note: FluentValidation is the go-to for non-trivial validation: complex, conditional, cross-property, or dependency-requiring rules are clean and testable, and keeping validation out of the model keeps both focused. The costs are a dependency and slightly more setup than annotations, plus the discipline of wiring validators in (manually invoking, or using the auto-validation integration). A common and effective pattern is to combine Ways 1 and 2: data annotations for simple structural rules (which also feed OpenAPI/client validation) and FluentValidation for the complex business rules. For the async-uniqueness style of check, be aware it adds a database round-trip to validation and has an inherent race (the SKU could be taken between the check and the insert), so back it with a database unique constraint as the real guarantee (see the idempotency use case).
Way 3: Minimal API Endpoint Filters (validation as a filter)
Best used for: Minimal API endpoints (which do not have the [ApiController] automatic validation of controllers) where you want to apply validation as a reusable filter across endpoints.
The concept: Minimal APIs do not auto-validate, so you add an endpoint filter that runs before the handler, validates the incoming model (using FluentValidation or annotations), and short-circuits with a 400 if invalid. Written once as a generic filter, it applies to any endpoint.
// A reusable validation filter for minimal API endpoints
public class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var model = context.Arguments.OfType<T>().FirstOrDefault();
if (model is null) return Results.BadRequest("Invalid request body.");
var result = await validator.ValidateAsync(model, context.HttpContext.RequestAborted);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary()); // 400 before the handler
return await next(context); // valid — proceed to the handler
}
}
// Apply to endpoints — the handler only runs with validated input
app.MapPost("/api/products", (CreateProductRequest req) => CreateProduct(req))
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
// Extension to make it tidy across many endpoints
public static RouteHandlerBuilder WithValidation<T>(this RouteHandlerBuilder builder)
=> builder.AddEndpointFilter<ValidationFilter<T>>();
app.MapPost("/api/orders", (CreateOrderRequest req) => CreateOrder(req))
.WithValidation<CreateOrderRequest>();
Why it works: The filter runs in the endpoint pipeline before the handler, so validation is centralised and reusable rather than repeated inside each handler. It cleanly bridges the gap that minimal APIs have no built-in model validation, and it composes with FluentValidation so you keep all the expressiveness of Way 2 while getting controller-style automatic rejection.
Benchmark note: Endpoint filters are the idiomatic way to add cross-cutting concerns (validation, logging, auth checks) to minimal APIs, keeping handlers focused purely on business logic. The negligible overhead is one filter invocation per request. This pattern is essential if you use minimal APIs and want the "invalid requests never reach my handler" guarantee that [ApiController] gives controllers for free. The same approach generalises — you can layer multiple filters — and it keeps validation declarative at the endpoint definition (.WithValidation<T>()) while the actual rules live in testable validator classes.
Way 4: Guard Clauses (fail fast inside methods)
Best used for: Defending method and constructor preconditions throughout your code — not boundary input validation, but the internal "this method requires a non-null, non-empty argument" checks that catch programming errors and enforce invariants.
The concept: Guard clauses are checks at the very start of a method or constructor that validate arguments and throw immediately if they are invalid — failing fast at the point of the error rather than letting bad values propagate to cause a confusing failure elsewhere. They protect invariants inside the code, complementing boundary validation.
public class Order
{
public Order(int customerId, decimal total, string currency)
{
// Guard clauses — fail immediately and clearly if preconditions are violated
if (customerId <= 0)
throw new ArgumentOutOfRangeException(nameof(customerId), "Must be positive.");
if (total < 0)
throw new ArgumentOutOfRangeException(nameof(total), "Cannot be negative.");
ArgumentException.ThrowIfNullOrWhiteSpace(currency); // .NET 8 built-in guard helpers
CustomerId = customerId;
Total = total;
Currency = currency;
}
public void ApplyDiscount(decimal percent)
{
if (percent is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percent), "Must be 0–100.");
Total *= (1 - percent / 100);
}
}
// .NET's built-in guard helpers reduce boilerplate:
ArgumentNullException.ThrowIfNull(service);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentOutOfRangeException.ThrowIfNegative(amount);
ArgumentOutOfRangeException.ThrowIfGreaterThan(count, maxCount);
Why it works: Guard clauses enforce a method's contract at its entry point, so an invalid argument fails immediately with a precise error naming the offending parameter — rather than being stored and causing a baffling NullReferenceException or corrupt state three layers deeper. They make preconditions explicit and keep the rest of the method free of defensive checks, since validity is guaranteed after the guards.
Benchmark note: Guard clauses are about internal correctness and debuggability, not user-facing input validation — they catch programming errors (a caller passing null) and enforce invariants, throwing exceptions meant for developers, not 400 responses meant for clients. The distinction matters: boundary validation (Ways 1–3) produces friendly error messages for external callers; guards produce exceptions that surface bugs. .NET 8's built-in ThrowIfNull/ThrowIfNullOrEmpty/ThrowIfNegative helpers make them one-liners with no custom code. Use guards liberally on public methods and constructors — they cost nothing on the happy path and turn "mysterious failure far from the cause" into "immediate, precise error at the cause". They are the last line of defence behind boundary validation, not a replacement for it.
Way 5: Domain Validation / Value Objects (make invalid states unrepresentable)
Best used for: Core domain concepts where validity is intrinsic — an email address, money amount, or date range that should never exist in an invalid form anywhere in the system — by encapsulating the rules in the type itself.
The concept: Instead of validating a raw string email repeatedly wherever it is used, you create a value object (an Email type) that validates on construction and cannot be created in an invalid state. Once you have an Email instance, it is guaranteed valid — the type system enforces the rule everywhere, and you "make invalid states unrepresentable".
// A value object that cannot exist in an invalid state
public readonly record struct Email
{
public string Value { get; }
private Email(string value) => Value = value;
// The ONLY way to create an Email — validation is unavoidable
public static Email Create(string input)
{
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentException("Email is required.");
input = input.Trim().ToLowerInvariant();
if (!System.Net.Mail.MailAddress.TryCreate(input, out _))
throw new ArgumentException($"'{input}' is not a valid email.");
return new Email(input);
}
// Or a non-throwing variant for boundary validation
public static bool TryCreate(string input, out Email email)
{
email = default;
if (string.IsNullOrWhiteSpace(input) || !System.Net.Mail.MailAddress.TryCreate(input.Trim(), out _))
return false;
email = new Email(input.Trim().ToLowerInvariant());
return true;
}
public override string ToString() => Value;
}
// Money as a value object enforcing its own invariants
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative.");
if (currency.Length != 3) throw new ArgumentException("Currency must be a 3-letter code.");
Amount = amount; Currency = currency.ToUpperInvariant();
}
}
// Usage — once you hold an Email or Money, it is GUARANTEED valid everywhere
public class User
{
public Email Email { get; } // not a string — cannot be an invalid email, ever
public User(Email email) => Email = email;
}
Why it works: By making validity a property of the type, the rule is enforced once (in the constructor/factory) and guaranteed everywhere the type is used — you never re-validate an Email because an Email cannot be invalid. This eliminates whole classes of bugs: no "did someone validate this string?" uncertainty, no scattered re-validation, no invalid data slipping through a path that forgot to check. The compiler enforces the domain rule.
Benchmark note: This is the most robust approach for core domain invariants — "make invalid states unrepresentable" turns runtime validation into compile-time guarantees, and the rule lives in exactly one place. The cost is ceremony: a value object per concept is more code than a raw primitive, so reserve it for genuinely important domain concepts (email, money, quantities, identifiers, date ranges) rather than wrapping every string. It composes with boundary validation — use TryCreate at the API edge to produce friendly errors, and the throwing constructor deep in the domain as a guarantee. Records (value objects as readonly record struct) make value objects concise and give value equality for free. For the data that matters most, this approach prevents invalid values from existing at all, which is stronger than any amount of checking.
Choosing the right approach (and how they layer)
- Simple property rules (required, length, range, format) → Data annotations (Way 1)
- Complex / conditional / cross-property / dependency rules → FluentValidation (Way 2)
- Minimal API endpoints needing validation → Endpoint filters (Way 3)
- Method/constructor preconditions, internal invariants → Guard clauses (Way 4)
- Core domain concepts that must always be valid → Value objects / domain validation (Way 5)
These layer into defence in depth. Validate at the boundary first (Ways 1–3) to reject bad external input with friendly error messages before it enters the system — annotations for simple rules, FluentValidation for complex ones, endpoint filters to wire it into minimal APIs. Behind that, guard clauses (Way 4) protect individual methods and constructors so programming errors fail fast and precisely. And for the most important domain concepts, value objects (Way 5) make invalid states impossible to represent at all, turning validation into a type-system guarantee. The guiding principles: validate at the boundary (reject bad input at the edge, so inner code trusts its data), fail fast with clear messages (a precise error at the source beats a mysterious failure deep in the stack), and never trust external input (every byte from outside is suspect until validated). And always back uniqueness/business-rule validation with a database constraint — application-level checks have races that only a constraint truly closes.
Note: this is an informational engineering overview. Choose validation layers appropriate to your application's complexity and security requirements. Always treat external input as untrusted and enforce critical invariants at the database level as well.
More from the playbooks
Mapping & Transforming Data (Entity to DTO)
You accidentally serialized a password hash to the API
Configuration & Secrets Management
A database password just got committed to Git
Time Zones & Dates Across Systems
The meeting shows at the wrong hour for half your users