# How a server feature is shaped Adding a command, a query, a validator, and the controller action that reaches them. > Last verified: 2026-07-30 against commit `d3ec723`. --- ## 1. The dispatcher is not MediatR CQRS runs on **`martinothamar/Mediator`** — a source-generator-based dispatcher. Use `ISender` / `ICommand` / `IQuery` from that package. Any prose anywhere that says "MediatR" is wrong; do not add MediatR types or `IMediator`. --- ## 2. The folder shape ``` Baya.Application/Features// ├── Commands/Command/ │ ├── Command.cs record : IRequest> │ ├── Command.Handler.cs internal sealed class : IRequestHandler<…> │ └── Command.Validator.cs AbstractValidator (omit when there is nothing to validate) └── Queries/Query/ ├── Query.cs ├── Query.Handler.cs └── Query.Result.cs record Result(…) ← the DTO returned ``` `Features/System/Queries/Ping/` is the minimal live example — query, handler, result — surfaced by `Controllers/V1/PingController`. One type per file, and the file name matches the type name. --- ## 3. The rules - **Requests are `record`s** — immutable, value semantics. - **Handlers are `internal sealed`** — they are never used outside the Application layer. - **Exactly one handler per request type.** No conditional dispatch. - **Never throw for an expected failure.** Return an `OperationResult`: | Factory | Maps to | | --- | --- | | `OperationResult.SuccessResult(value)` | 200 | | `OperationResult.FailureResult(errors)` | 400 — validation or business-rule failure, with field-level detail | | `OperationResult.NotFoundResult(message)` | 404 | | `OperationResult.ConflictResult(message)` | 409 — idempotency, duplicate, or an illegal state transition | Let a genuinely *unexpected* exception propagate to the global `ExceptionHandler` middleware. Don't try/catch unknown exceptions in a handler, and never swallow one into a `FailureResult`. - **Contracts the handler depends on are interfaces in `Application/Contracts/`**, implemented in Infrastructure. A handler never references a concrete infrastructure type. - **Validators are FluentValidation** `AbstractValidator`, auto-registered from the Application assembly by `AddApplicationServices` and run by the `ValidateCommandBehavior` pipeline behavior before the handler. Validate **at the boundary** — the command or query — not deep in the domain or a repository. ```csharp public sealed class CreateOrderCommandValidator : AbstractValidator { public CreateOrderCommandValidator() { RuleFor(x => x.UserId).GreaterThan(0); RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item."); RuleForEach(x => x.Items).ChildRules(item => { item.RuleFor(i => i.ProductId).GreaterThan(0); item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100); }); } } ``` **A route-supplied id must NOT be validated in the body command.** Route values (e.g. `patients/update/{id}`) aren't bound into the body, so a `GreaterThan(0)` on them fails every request. - **Pipeline order is Logging → Metrics → Validate.** A new behavior slots into that chain in `AddApplicationServices`, not into a handler. --- ## 4. The controller Every controller follows this skeleton: ```csharp [ApiVersion("1")] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] [Display(Description = "One-line description shown in Swagger")] [Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public public sealed class MyFeatureController(ISender sender) : BaseController { [HttpGet("[action]")] [ProducesOkApiResponseType] public async Task GetSomething(CancellationToken ct) => OperationResult(await sender.Send(new MyQuery(), ct)); [HttpPost("[action]")] [ProducesOkApiResponseType] public async Task CreateSomething(MyCommand command, CancellationToken ct) => OperationResult(await sender.Send(command, ct)); } ``` - **`sealed`.** Controllers are not designed for inheritance beyond `BaseController`. - **Inject `ISender` via the primary constructor**, not `IMediator`. - **Never call `Ok()`, `BadRequest()`, or `NotFound()` directly.** Always `base.OperationResult(result)` — that is what maps `OperationResult` (including 401/403/409) onto the envelope every client already parses. - **Keep the method thin: one `Send`, one `OperationResult`.** No business logic in a controller. - **Use `[Display(Description = "…")]`** so NSwag generates meaningful Swagger tags. - **Pass the `CancellationToken`** from the action into `sender.Send(...)`. - **Route segments come from `[controller]`/`[action]` tokens**, which `SnakeCaseParameterTransformer` converts. Never hardcode a route string — that bypasses the transformer. If a method name doesn't read cleanly as a URL, **rename the method**. ### Authorization — the narrowest that fits | Attribute | When | | --- | --- | | *(none)* | Truly public — health check, metrics, a webhook (which is signature-verified instead) | | `[Authorize]` | Any authenticated user | | `[Authorize(ConstantPolicies.DynamicPermission)]` | A role/claim-gated admin action | | `[RequireTokenWithoutAuthorization]` | A token must be present but may be expired — the refresh endpoint | Apply at the **controller** level for a uniform policy; override at the action level only for a genuine exception. Least privilege: an admin action gets `DynamicPermission`, not a bare `[Authorize]`. Rate-limit the sensitive ones — see [identity.md](identity.md) §5. --- ## 5. To add a feature 1. Create the folder under `Features//{Commands|Queries}//`. 2. Implement the request, the handler, and a validator if it takes input. 3. Add any new dependency as an interface in `Application/Contracts/`, and implement it in Infrastructure — mock and real both, if it is an external rail. See [structure.md](structure.md) §3. 4. Wire a controller action to `sender.Send(...)`. 5. Add handler unit tests (NSubstitute) **and** at least one `WebApplicationFactory` integration test for the area: happy path 200, unauthenticated 401, validation 400. See [conventions.md](conventions.md) §5. 6. Publish the endpoint's contract to [`docs/integration/`](../../integration/index.md). If the feature adds a table, read [persistence.md](persistence.md) first — the money, snapshot, state-machine and soft-delete rules there are invariants, not suggestions.