Software Engineering
A Practical Guide to Validation in ASP.NET Core APIs
Validation is not one pipeline behaviour. A reliable API separates malformed input, command rules, domain invariants, authorisation and concurrency conflicts-and gives clients a stable way to tell them apart.
Most validation problems are classification problems
An order request can be invalid because its JSON is malformed, a required field is absent, its delivery date precedes its order date, the account is suspended, the caller cannot use that account, or another request has already changed the order. Calling all six “validation” produces controllers full of checks and clients that can only display “something went wrong.”
The useful question is which boundary owns each decision. Model binding establishes whether the wire representation can become the endpoint's input type. Request validation rejects values that are nonsensical for this operation. The domain protects rules that must hold regardless of whether a command arrives through HTTP, a queue or an administrator tool. Authorisation decides who may act. Persistence resolves uniqueness and concurrency against current data.
These layers may all reject the same HTTP request, but they should not become one validation service. They have different dependencies, lifetimes and failure semantics. A regex can be evaluated before database access; “customer has sufficient credit” cannot. A uniqueness pre-check can improve an error message, but only a database constraint can settle a race.
Use a validation map before choosing a library
| Question | Owner | Typical result |
|---|---|---|
| Can this payload be parsed and bound? | ASP.NET Core input formatting and model binding | 400 with problem details |
| Is this command internally coherent? | Endpoint/request validator | 400 or 422 by documented convention |
| May this caller perform the action? | Authorisation policy | 401 or 403 |
| Is the transition legal for the aggregate? | Domain model or application service | Domain rejection mapped to a stable API error |
| Is referenced/current state compatible? | Application or domain service | Conflict or domain rejection |
| Did another writer win? | Persistence boundary | 409 conflict, sometimes with retry guidance |
The exact status choice matters less than a consistent published contract. Some APIs use 400 for every semantically invalid request; others reserve 422 for a well-formed representation that cannot be processed. Clients can support either. They cannot reliably support endpoints that alternate according to which exception happened to escape.
Model binding failure is not a business-rule failure
ASP.NET Core can reject malformed JSON, conversion failures and binding errors before endpoint logic runs. With controllers using [ApiController], invalid model state can automatically produce a response. Minimal APIs have different validation integration points depending on the application and framework version. In both cases, customise the final problem-details shape centrally rather than hand-building responses in each endpoint.
Nullability is an easy source of accidental contracts. A non-nullable property tells the serializer and validation system something about absence, but it does not express every business requirement. Patch operations, omitted values and explicit null have distinct meanings that a single DTO may blur. Use endpoint-specific request types and model optionality deliberately.
Do not bind persistence entities directly. Besides over-posting risk, entities combine stored state with fields the caller may set and rules the endpoint may not own. A request DTO should describe the command the API accepts. Mapping is useful friction: it forces the team to decide which input crosses the trust boundary.
Data annotations are good at local truths
Annotations work well for stable, context-free constraints such as length and simple ranges. They are discoverable and integrate with framework validation. They become awkward when rules are conditional, shared across only some commands, asynchronous, or expressed in domain language. A property decorated with every rule from every use case is not reusable; it is coupled to several endpoints at once.
Attributes also tempt teams to attach database-dependent checks to object validation. That hides I/O in a phase developers expect to be cheap and deterministic, complicates dependency injection, and can cause repeated queries. Keep annotations local. Move multi-field command rules into an explicit validator and current-state decisions into the application flow where cancellation, timeouts and query cost are visible.
FluentValidation improves expression, not ownership
FluentValidation is valuable for composing conditional rules, collections, reusable rule sets and testable error codes. It does not decide where a rule belongs. A validator that loads an account, checks inventory, calls a sanctions service and evaluates permissions has become an application service with an misleading name.
Keep request validators deterministic where practical. They should answer whether the proposed command is coherent without changing state. If a rule requires current data, make the dependency explicit and assume the answer can become stale before the write. Validation must not perform side effects: frameworks and developers may execute it more than once.
Be cautious with automatic validation pipelines when rules are asynchronous. A pipeline designed around synchronous model validation is the wrong place to conceal network or database calls. Explicitly invoking an asynchronous validator in the application path often produces clearer cancellation, telemetry and failure handling.
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerId).NotEmpty()
.WithErrorCode("customer.required");
RuleFor(x => x.Lines).NotEmpty()
.WithErrorCode("order.lines_required");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(x => x.Quantity).GreaterThan(0)
.WithErrorCode("order.quantity_positive");
});
}
}
The error code is more durable than the English message. The message can be localised or improved; clients should branch on a documented machine-readable code and field location.
Domain invariants must survive every entry point
If an order cannot be dispatched after cancellation, that rule belongs with the operation that changes order state. Enforcing it only in an HTTP validator means a queue consumer, import job or future endpoint can create an impossible order. Boundary validation protects an endpoint contract; domain validation protects the business model.
This does not require every entity to become a fortress of private setters. It requires one authoritative path for important state transitions and tests against the invariant. Domain failures should use stable reasons that the API layer can map, not HTTP status codes embedded in the domain.
Rules involving several aggregates or an external authority usually belong in a domain or application service. Avoid pretending a distributed check is an invariant if it cannot be atomic. A payment provider can approve a charge and time out before the response arrives; “validate then act” does not solve that. Idempotency and reconciliation are part of correctness.
Uniqueness checks are helpful lies unless backed by constraints
Checking whether an email or reference already exists can produce a friendly response, but two concurrent requests can both pass the check. Only a unique database constraint arbitrates the race. Keep the pre-check if it improves the common case, then translate the specific constraint violation into the same stable conflict response.
Optimistic concurrency works similarly. A version or ETag lets the API detect that the representation changed since the client read it. That is not a field-validation error: the submitted values may be individually correct but based on stale state. Return a conflict that tells the client whether to reload, merge or retry. Blind automatic retries can overwrite the very change concurrency control exposed.
Problem Details is an envelope, not an error strategy
RFC-style problem details gives errors a standard outer shape. The API still needs stable semantics. Include a type or code, a safe title, status, correlation identifier and-when useful-field errors. Do not expose exception messages, SQL constraint names, stack traces or values that reveal whether another user's resource exists.
{
"type": "https://api.example.com/problems/validation",
"title": "The request is invalid",
"status": 400,
"traceId": "00-4f...",
"errors": [
{
"code": "order.quantity_positive",
"path": "lines[1].quantity",
"message": "Quantity must be greater than zero."
}
]
}
A dictionary of field names to messages is convenient for forms but insufficient for every client. Paths can change when DTOs evolve, messages can be localised, and some errors concern the command rather than a field. Codes provide the durable contract. Document whether clients may show messages directly or should map codes to their own copy.
Validation order is a performance and security decision
Run cheap structural checks before expensive state lookups, but do not let validation disclose protected information. An unauthorised caller should not learn that an invoice exists because a validator returned “invoice is already paid.” Establish the appropriate identity and resource boundary before returning detailed state-dependent errors.
Batch APIs need explicit limits. Validating ten thousand nested items can consume considerable CPU and memory before rate limiting or business logic begins. Set request-size, collection-size and nesting constraints at the edge. Report a bounded number of failures; clients rarely benefit from a megabyte of repetitive errors.
Cancellation should flow through asynchronous checks, and validation dependencies need the same timeout and resilience policy as other calls. Decide whether an unavailable reference service means reject, retry or fail the operation. It does not mean “the input is invalid.” Operational failure and user error must remain distinguishable in metrics and responses.
Test the contract at the layer that owns it
- Unit-test deterministic request rules with boundary values and error codes.
- Unit-test domain transitions through public operations, including prohibited prior states.
- Integration-test model binding, JSON settings and the actual problem-details response.
- Integration-test unique constraints and optimistic concurrency against the real database engine.
- Test that unauthorised callers cannot use validation responses to enumerate resources.
- Consumer-test error codes when external clients depend on them.
A test that calls a validator directly cannot prove the endpoint invokes it. An endpoint test that mocks persistence cannot prove a uniqueness race is handled. Place evidence where the failure can actually occur.
A production-ready validation checklist
- Use operation-specific request models and intentional nullability.
- Separate parsing, request coherence, authorisation, domain rules and concurrency.
- Keep side effects out of validation.
- Back uniqueness with database constraints.
- Return consistent problem details with stable machine-readable codes.
- Bound payload size, collection size and reported error count.
- Preserve cancellation and distinguish dependency failure from invalid input.
- Log codes and correlation context without sensitive submitted values.
The goal is not to reject every bad request as early as theoretically possible. It is to make each rule authoritative, race-safe, observable and understandable to the client that must recover from it.
Related C3 Software guides
Frequently asked questions
Should an API return 400 or 422 for validation errors?
Either convention can work. Use 400 consistently for invalid requests, or use 422 for well-formed representations that violate command rules. Document the distinction and keep stable error codes independent of the status.
Should FluentValidation contain database queries?
Usually not in automatic model validation. Current-state checks are often clearer in the application flow, where asynchronous I/O, cancellation and race conditions are explicit.
Are data annotations enough?
They are enough for simple local constraints. They are a poor home for conditional command rules, I/O, cross-aggregate decisions or invariants that other entry points must also enforce.
How should uniqueness be validated?
Use a pre-check for usability if useful, enforce a unique database constraint for correctness, and translate the constraint violation into the documented conflict response.
Build APIs whose failures clients can handle
C3 Software Limited has built and supported business software in the UK since 2001. If validation rules are duplicated across controllers, jobs and domain code, talk to C3 Software about an API architecture review or implementation plan.