FluentValidation — global action filter, structural rules only
FluentValidation was a referenced dependency with zero validators for a
while — now wired up as a global pipeline. Adding a new AbstractValidator<T>
is the only step needed to enforce it; no controller changes, no per-action
ValidateAsync() calls.
Auto-discovery
Program.cs calls builder.Services.AddValidatorsFromAssemblyContaining<ChoiceDto>();
— anchored on a DTO type, not Program, since validators live in Application
(alongside their DTOs), not Api. Every AbstractValidator<T> in that assembly
is found automatically.
Enforcement
Api/Filters/ValidationActionFilter.cs, a global IAsyncActionFilter
registered via AddControllers(options => options.Filters.Add<ValidationActionFilter>()).
For each action argument, it resolves IValidator<T> from DI by the argument's
runtime type and runs it if one exists — a no-op for any DTO with no
registered validator, so registering it globally doesn't change behavior for
anything except DTOs that actually get a validator. This app is
[ApiController] MVC, not Minimal APIs, so this is an action filter, not an
endpoint filter.
Response shape
Failures short-circuit the action with 400 +
Microsoft.AspNetCore.Mvc.ValidationProblemDetails
(errors: { field: [messages] }) built from a ModelStateDictionary populated
from FluentValidation's ValidationResult.Errors — the same shape
[ApiController]'s own built-in model validation already produces, so callers
see one consistent error shape either way.
Where validators live
Validators live in Application/Modules/Mas/Validators/, one file per DTO,
named <Entity>DtoValidator.cs — mirrors the module's existing Dto//Services/
split.
Structural, not business logic
Rules are structural, not business logic: NotEmpty() on required/identifying
fields, MaximumLength(N) where N mirrors the DTO's real backing column
width (read the scaffolded entity's EosDbContext mapping — never invent a
number). Large DTOs that mirror a legacy SP's full parameter list (Branch ~70
params, Company ~35, TrCode ~150) only get rules on their genuinely
primary/identifying fields (Code, Name, and similar) — not every optional
column.
This is deliberate scope, not an oversight: exhaustively validating every field of every DTO is a separate, larger decision than "wire up the pipeline," and existing ad-hoc null-check validation already scattered in repositories/controllers is untouched by this pattern — the two coexist rather than one replacing the other (for now).
Rollout status
Rolled out to every Operations DTO that accepts a request body (13 total:
Address, Branch, Choice, City, Client, Company, Country, Department, Equipment,
ForumType, Queue, TrCode, Warehouse) via one parallel agent per DTO, each given
ChoiceDtoValidator as the exact reference pattern.
AddressTypeDto/ChoiceTypeDto have no validator — those controllers are
read-only, no request body exists to validate. LoginRequest (the one DTO on
the unversioned api/auth route) also has no validator yet — out of scope for
this rollout, which was scoped to the versioned Operations surface; add one the
same way if that changes.
Reference files: ChoiceDtoValidator.cs (the original hand-built reference —
read this first before writing a new validator), ValidationActionFilter.cs,
Program.cs's AddValidatorsFromAssemblyContaining/AddControllers calls.
Man* stored procedure result codes
Structural validation catches malformed input before it ever reaches a legacy stored procedure — but a well-formed request can still be rejected by the SP itself for a real business reason. Handling that second layer correctly is a distinct, equally required discipline for any write path.
A legacy Man* stored procedure's negative/non-positive @Result on failure
is signaling a real, specific business-rule rejection — never a generic
error. Before considering a Create/Update/Cancel/Delete write path done for
any entity (Mas* or Operations-module/transactional), grep the SP's
entire body (every @Action/@ActionType branch, not just the one or two
actions actually being implemented right now) for every distinct @Result
value it can return, plus any @Alert/@ErrorMsg-style output parameter —
then map failures to ServiceResult.Conflict with the SP's own real message
where one exists, not a generic "operation failed (result: X)" fallback.
:::danger Confirmed necessary the hard way, not a hypothetical
WarehouseOrderRepository's CreateAsync originally just threw a raw
exception on any negative @Result, so a real, first-time-encountered
business rejection from WMS_ManWOHR (@Result=-3,
@Alert="Decision management could not find any Event list.", for a
Branch/Client/OrderType combination with no configured Event list) surfaced to
the API caller as an unhandled 500 with a full .NET stack trace instead of a
clean 409 carrying the real reason.
The same anti-pattern existed in MasterOrderRepository too — fixed in the
same pass even though it hadn't been hit yet, since it was the identical bug
waiting to happen.
:::
This is a standing requirement for every future Operations-module write path,
not a one-off fix — a full result-code enumeration (a "what can this Action
code actually return" pass over the real SP body) is part of what "done" means
for a Create/Update/Cancel/Delete implementation, the same way a
column-to-parameter mapping check already is (see the MasterOrder/Warehouse
Order entries in TASKS.md's legacy stored-procedure inventory table for the
worked examples).