diff --git a/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md b/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md new file mode 100644 index 0000000..c701ea9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md @@ -0,0 +1,120 @@ +# 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;` and + `public class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : + IRequestHandler` directly below it in + the same file. Void operations (`Archive`, `Delete`, `Remove`) use + `IRequest` (no generic parameter) instead of `IRequest`. +- `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()` 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.