create mvp path
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user