Documents the plan to replace the 9 IXService interfaces with MediatR commands/queries + handlers, keeping IPermissionService, ITokenService, and IPasswordHasher as plain injected helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
5.7 KiB
Markdown
121 lines
5.7 KiB
Markdown
# 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.
|