ADR 0019: Field-Level Validation with ICommandValidator
Status
Accepted
Context and Problem Statement
Before this change, handlers validated input inline and returned on the first failure. Two problems arose:
First-error-only: a client sending a request with both
titleanddescriptionmissing received only the title error. It had to submit multiple requests to discover all validation failures.Inconsistent ProblemDetails shape:
SearchTicketsAsyncproduced aValidationProblemDetailswith anerrorsfield (viaResults.ValidationProblem), while all other endpoints produced a flatProblemDetailswith a singlecodefield. Clients had to handle two different 400 shapes.
Additionally, max-length violations surfaced as DomainViolation with code
domain.max_length — no field name, wrong semantic layer (domain enforcing a
transport constraint).
Decision Drivers
- A single 400 response must carry all invalid fields at once.
- All 400 validation responses must have the same shape regardless of endpoint.
- Format constraints (required, max-length) belong in the Application layer;
the Domain's
Guardis the last line of defence, not the first. - The solution must compose cleanly with a future MediatR pipeline.
Considered Options
Option A — Inline collection per handler
Collect errors in a Dictionary<string, List<string>> inside each handler
before returning. No new types.
Simple but spreads the collection logic across every handler with no shared
contract. Harder to wire into a MediatR IPipelineBehavior later.
Option B — ICommandValidator<TCommand> (Selected)
Introduce a dedicated interface in the Application layer:
public interface ICommandValidator<TCommand>
{
FieldValidationResult Validate(TCommand command);
}
FieldValidationResult carries IsValid and IReadOnlyDictionary<string, string[]> FieldErrors.
FieldValidationBuilder provides a fluent API for accumulating errors.
Concrete validators live next to their command in the use-case folder.
Handlers inject ICommandValidator<TCommand> and delegate validation before
any domain logic runs.
Option C — FluentValidation
Industry-standard library with a rich rule DSL. Adds a NuGet dependency and a learning curve that is not justified for the current scope. Can be introduced later if the validator count grows significantly.
Decision Outcome
Chosen option: Option B — ICommandValidator<TCommand>.
Why this trade-off makes sense for this project
- All field errors in one response.
FieldValidationBuilder.Build()returns aFieldValidationResultwith every failing field collected, not just the first. - Consistent ProblemDetails shape.
ApiProblemDetailsFactorydetectsApplicationError.FieldErrorsand producesValidationProblemDetailswith anerrorsobject — the same shape previously produced only bySearchTicketsAsync.SearchTicketsAsyncis migrated to the sameResultpipeline. - Correct layer ownership. Required and max-length checks now live in the
Application validator, not in the Domain. The Domain
Guardremains as a defence-in-depth invariant, not a transport validator. - MediatR-ready.
ICommandValidator<TCommand>is an Application-layer interface with no ASP.NET dependency. A futureValidationBehavior<TRequest, TResponse>can resolve it from DI without touching the validators themselves.
Response shape for validation failures
{
"status": 400,
"title": "Validation failed",
"errors": {
"title": ["Title is required."],
"description": ["Must not exceed 2000 characters."]
},
"code": "create_ticket.validation_failed",
"errorType": "validation",
"traceId": "..."
}
Validator Applicability Rule
Not every command needs a validator. The rule is:
Create an
ICommandValidator<TCommand>when the command contains at least one field that can be syntactically valid but violate an application-layer constraint (required, max-length, format). Strongly-typed fields that cannot represent an invalid value after deserialization do not need a validator.
Field type classification
| Field type | Needs validator? | Reason |
|---|---|---|
string |
Yes (if required or bounded) | Can be empty, whitespace, or too long |
string? |
Yes (if bounded when provided) | Can be whitespace or too long when not null |
TicketId / Guid |
No | Cannot be invalid after binding |
TicketStatus / enum |
No | Deserialization rejects unknown values |
DateTimeOffset? |
No | Parsed by the runtime or rejected |
Examples
| Command | Validator? | Why |
|---|---|---|
CreateTicketCommand |
Yes | Title, Description are required strings with max length |
AssignTicketCommand |
Yes | AssigneeName is an optional string with max length |
AddCommentCommand |
Yes | Content is required, Author is bounded |
ChangeTicketStatusCommand |
No | Only TicketId + TicketStatus – both strongly typed |
Transition logic is not input validation
The TicketStatus transition check (CanTransition) is domain logic, not
input validation. It is enforced by TicketWorkflow inside the domain aggregate
and mapped to Result<T>.Conflict in the handler. It must not be placed in an
ICommandValidator.
Consequences
Positive Consequences
- Clients receive all field errors in a single round-trip.
- One 400 response shape across all endpoints.
- Validators are independently testable units.
- Handler bodies are free of format-validation noise.
Negative Consequences
- Each new command that requires validation needs a corresponding validator class registered in DI.
ICommandValidator<TCommand>is a custom interface, not a standard one — teams unfamiliar with the codebase must learn it.
Re-evaluation Triggers
Revisit when:
- MediatR is introduced — migrate to a
ValidationBehavior<TRequest, TResponse>that resolvesICommandValidator<TRequest>automatically, removing the explicit validator call from each handler. - Validation rules grow complex enough to justify FluentValidation's DSL (cross-field rules, async validators, localisation).
Related
- ADR 0002 – Result Pattern as Application Error Contract
- ADR 0003 – RFC 9457 ProblemDetails as the Unified Error Response Contract
- ADR 0004 – Minimal API without MediatR
src/ServiceDeskLite.Application/Common/Validation/ICommandValidator.cssrc/ServiceDeskLite.Application/Common/Validation/FieldValidationResult.cssrc/ServiceDeskLite.Application/Common/Validation/FieldValidationBuilder.cssrc/ServiceDeskLite.Application/Common/ApplicationError.cssrc/ServiceDeskLite.Api/Http/ProblemDetails/ApiProblemDetailsFactory.cs