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>
5.7 KiB
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) tomws.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) —EnsureAsyncis called from insideAccountService's methods (soonAccount*Handlers) and directly inRolesController. Converting it to a mediator request would mean handlers callingSend()on other handlers, which MediatR's own docs call out as an anti-pattern.GetMenuAsyncstays 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.cscontainingpublic record CreateProjectCommand(Guid UserId, string Name, string? Description) : IRequest<ProjectDto>;andpublic class CreateProjectHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<CreateProjectCommand, ProjectDto>directly below it in the same file. Void operations (Archive,Delete,Remove) useIRequest(no generic parameter) instead ofIRequest<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
IUnitOfWorkcalls, same exceptions (NotFoundException,ForbiddenException,BadRequestException), same authorization checks (GetMemberRoleAsync,EnsureMemberAccessAsync, etc.). No logic changes. - The
Guid userId/actorUserIdparameter that every old service method took explicitly becomes the first property on the command/query record (handlers have no ambient HTTP context). - Delete
IXService.csandXService.csfor 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,IPasswordHasherregistrations 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:
dotnet build Mws.slnxsucceeds with no errors.dotnet run --project Mws.Api, then smoke-test with curl: login (Auth), list + create a project (Projects, command + query path), get menu (confirmsIPermissionServicestill 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
IUnitOfWorkrepositories.