Files
mws.backend.dotnet/docs/superpowers/specs/2026-08-13-cqrs-mediator-design.md
T
namdhandClaude Sonnet 5 96944ad2c3 Add design spec for CQRS/mediator refactor
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>
2026-08-13 22:56:20 +07:00

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) 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*Handlers) 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.