6.7 KiB
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/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>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
records — 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<T>.SuccessResult(value)200 OperationResult<T>.FailureResult(errors)400 — validation or business-rule failure, with field-level detail OperationResult<T>.NotFoundResult(message)404 OperationResult.ConflictResult(message)409 — idempotency, duplicate, or an illegal state transition Let a genuinely unexpected exception propagate to the global
ExceptionHandlermiddleware. Don't try/catch unknown exceptions in a handler, and never swallow one into aFailureResult. -
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<TRequest>, auto-registered from the Application assembly byAddApplicationServicesand run by theValidateCommandBehaviorpipeline behavior before the handler. Validate at the boundary — the command or query — not deep in the domain or a repository.public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand> { 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 aGreaterThan(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:
[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<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
sealed. Controllers are not designed for inheritance beyondBaseController.- Inject
ISendervia the primary constructor, notIMediator. - Never call
Ok(),BadRequest(), orNotFound()directly. Alwaysbase.OperationResult(result)— that is what mapsOperationResult(including 401/403/409) onto the envelope every client already parses. - Keep the method thin: one
Send, oneOperationResult. No business logic in a controller. - Use
[Display(Description = "…")]so NSwag generates meaningful Swagger tags. - Pass the
CancellationTokenfrom the action intosender.Send(...). - Route segments come from
[controller]/[action]tokens, whichSnakeCaseParameterTransformerconverts. 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 §5.
5. To add a feature
- Create the folder under
Features/<Area>/{Commands|Queries}/<VerbNoun>/. - Implement the request, the handler, and a validator if it takes input.
- 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 §3. - Wire a controller action to
sender.Send(...). - Add handler unit tests (NSubstitute) and at least one
WebApplicationFactoryintegration test for the area: happy path 200, unauthenticated 401, validation 400. See conventions.md §5. - Publish the endpoint's contract to
docs/integration/.
If the feature adds a table, read persistence.md first — the money, snapshot, state-machine and soft-delete rules there are invariants, not suggestions.