Files
baya-monorepo/archive/docs/rules/server/cqrs.md
T
2026-08-02 20:01:31 +03:30

150 lines
6.7 KiB
Markdown

# 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 `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<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 `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<TRequest>`, 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<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 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<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 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/<Area>/{Commands|Queries}/<VerbNoun>/`.
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.