Skip to main content

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