Files
mws.backend.dotnet/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md
T

121 lines
5.7 KiB
Markdown
Raw Normal View History

2026-08-13 22:56:20 +07:00
# CQRS/Mediator Refactor — Design
## Goal
Replace the `IXService`/`XService` application layer (9 interfaces, ~38
methods, injected directly into controllers) with MediatR commands/queries
and handlers. Controllers depend on `ISender` only; each HTTP action sends
one request and returns the result. No behavior change — this is a
structural refactor, not a feature change.
## Why
Current pattern is service-per-aggregate with broad interfaces
(`IProjectService` has 7 methods covering reads, writes, search). CQRS
splits each operation into its own request type, so each handler has one
job, and cross-cutting concerns (validation, logging, auth) can later be
added as MediatR pipeline behaviors without touching every service class.
## Package
- Add `MediatR` (latest v13) to `mws.application.csproj`.
- Note: MediatR (Jimmy Bogard) moved to a commercial license for
non-OSS/non-trivial use starting v13, mirroring AutoMapper (already used
in this repo). Explicitly accepted by the project owner for this refactor;
AutoMapper is out of scope and left as-is.
## Scope
All 7 request-bearing service modules, converted in one pass (not piloted):
| Module | Old interface | Methods |
|---|---|---|
| Auth | `IAuthService` | `LoginAsync` |
| Accounts | `IAccountService` | `GetAccountsAsync`, `CreateAccountAsync`, `UpdateAccountAsync`, `DeleteAccountAsync`, `ResetPasswordAsync` |
| Roles | `IRoleService` | `GetRolesAsync`, `GetRoleAsync`, `CreateRoleAsync`, `UpdateRoleAsync`, `DeleteRoleAsync` |
| Projects | `IProjectService` | `GetProjectsAsync`, `GetProjectAsync`, `CreateProjectAsync`, `UpdateProjectAsync`, `ArchiveProjectAsync`, `GetOverviewAsync`, `SearchAsync` |
| ProjectMembers | `IProjectMemberService` | `GetMembersAsync`, `AddMemberAsync`, `UpdateMemberDocumentPermissionsAsync`, `RemoveMemberAsync` |
| Documents | `IDocumentService` | `GetTreeAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `MoveAsync`, `DeleteAsync`, `SearchAsync` |
| Tasks | `ITaskService` | `GetTasksAsync`, `GetAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`, `SearchAsync` |
**Excluded from conversion** (stay plain injected services, unchanged):
- `IPermissionService` (`EnsureAsync`, `GetMenuAsync`) — `EnsureAsync` is
called from inside `AccountService`'s methods (soon `Account*Handler`s)
and directly in `RolesController`. Converting it to a mediator request
would mean handlers calling `Send()` on other handlers, which MediatR's
own docs call out as an anti-pattern. `GetMenuAsync` stays alongside it
for consistency (one small interface, one caller: `MenuController`).
- `ITokenService`, `IPasswordHasher` — infrastructure helpers consumed by
handlers, not themselves endpoint operations.
## Conventions
Per module folder (e.g. `mws.application/Projects/`):
- `Commands/` — one file per write operation: `CreateProject.cs` containing
`public record CreateProjectCommand(Guid UserId, string Name, string?
Description) : IRequest<ProjectDto>;` and
`public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) :
IRequestHandler<CreateProjectCommand, ProjectDto>` directly below it in
the same file. Void operations (`Archive`, `Delete`, `Remove`) use
`IRequest` (no generic parameter) instead of `IRequest<T>`.
- `Queries/` — same colocation pattern for reads: `GetProjects.cs`,
`SearchProjects.cs`, etc.
- `Contracts.cs` (DTOs) stays exactly as-is.
- Handler bodies are the old service method bodies moved verbatim — same
`IUnitOfWork` calls, same exceptions (`NotFoundException`,
`ForbiddenException`, `BadRequestException`), same authorization checks
(`GetMemberRoleAsync`, `EnsureMemberAccessAsync`, etc.). No logic changes.
- The `Guid userId`/`actorUserId` parameter that every old service method
took explicitly becomes the first property on the command/query record
(handlers have no ambient HTTP context).
- Delete `IXService.cs` and `XService.cs` for every converted module once
its controller no longer references the interface.
## Controllers
Each controller's constructor param changes from `IXService x` to
`ISender sender` (`MediatR`). Each action body changes from
`await x.SomeAsync(User.GetUserId(), ...)` to
`await sender.Send(new SomeCommand(User.GetUserId(), ...), ct)`. Routes,
HTTP verbs, status codes, and response types are unchanged.
`RolesController` and `MenuController` keep their existing
`IPermissionService` dependency alongside the new `ISender`.
## DI wiring
`mws.infrastructure/DependencyInjection.cs`:
- Remove the 8 `services.AddScoped<IXService, XService>()` lines for the 7
converted modules (Auth, Accounts, Roles, Projects, ProjectMembers,
Documents, Tasks).
- Add `services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Mws.Application.Common.IUnitOfWork).Assembly));`
- Keep `IPermissionService`, `ITokenService`, `IPasswordHasher`
registrations unchanged.
## Error handling
Unchanged. Handlers throw the same `Mws.Application.Common.Exceptions`
types the services did; `ApiExceptionMiddleware` still catches them at the
API boundary — nothing about that pipeline changes.
## Testing / verification
No test project exists in this repo (per `CLAUDE.md`, none expected today).
Verification is:
1. `dotnet build Mws.slnx` succeeds with no errors.
2. `dotnet run --project Mws.Api`, then smoke-test with curl:
login (Auth), list + create a project (Projects, command + query path),
get menu (confirms `IPermissionService` still wired and untouched).
## Out of scope
- AutoMapper stays as-is.
- No FluentValidation / MediatR pipeline behaviors — not requested, and the
ladder says don't add abstractions nobody asked for. Can be layered on
later since the request/handler shape is already in place.
- No repository/read-model split — queries still go through the existing
`IUnitOfWork` repositories.