Files
baya-monorepo/docs/rules/server/cqrs.md
T
2026-07-30 02:26:52 +03:30

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

    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:

[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 §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 §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 §5.
  6. 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.