Documentation
adrs/018-removing-mediatr-cqrs.md
ADR-018: Removing MediatR and CQRS Patterns
Status
Accepted
Context
The system initially adopted MediatR and the CQRS (Command Query Responsibility Segregation) pattern for handling API requests. This introduced request/response objects, handlers, pipeline behaviors for cross-cutting concerns, and a mediator pattern for decoupling controllers from business logic.
However, this approach has proven to add unnecessary complexity:
- Additional abstraction layers without clear benefits
- Harder to navigate and debug code
- Performance overhead from reflection and pipeline processing
- Increased learning curve for new developers
- Over-engineering for straightforward CRUD operations
Decision
We will remove MediatR and CQRS patterns from the Dynaplex architecture and replace them with direct service calls from minimal API endpoints.
Migration approach:
- Remove MediatR package references
- Convert handlers to service methods
- Move pipeline behaviors to middleware or filters
- Simplify request/response flow
- Maintain business logic in service layer
Before (MediatR/CQRS)
// Request/Command
public record CreateAssetCommand(string Name, int TypeId) : IRequest<Asset>;
// Handler
public class CreateAssetHandler : IRequestHandler<CreateAssetCommand, Asset>
{
public async Task<Asset> Handle(CreateAssetCommand request, CancellationToken ct)
{
// Business logic
}
}
// API Endpoint
app.MapPost("/assets", async (CreateAssetCommand command, IMediator mediator) =>
{
var result = await mediator.Send(command);
return Results.Ok(result);
});
After (Direct Services)
// Service
public class AssetService
{
public async Task<Asset> CreateAssetAsync(string name, int typeId)
{
// Business logic
}
}
// API Endpoint
app.MapPost("/assets", async (CreateAssetRequest request, AssetService service) =>
{
var asset = await service.CreateAssetAsync(request.Name, request.TypeId);
return TypedResults.Created($"/assets/{asset.Id}", asset);
});
Consequences
Positive
- Simplicity: Direct, easy-to-follow code flow
- Performance: No mediator overhead or reflection
- Debugging: Straightforward call stack and debugging
- Learning Curve: Easier for new developers to understand
- Less Boilerplate: No request/response/handler classes
- IDE Navigation: Direct F12 navigation to implementation
Negative
- Coupling: Endpoints more coupled to services
- Testing: Cannot test handlers in isolation
- Cross-Cutting Concerns: Must use middleware/filters instead of behaviors
- Consistency: Lose enforced request/response pattern
- Migration Effort: Significant refactoring required
Neutral
- Architecture Style: Move from CQRS to service-oriented
- Code Organization: Different folder/file structure
- Dependency Injection: Services instead of handlers
Implementation Notes
Migration Strategy
Phase 1: Identify Components
// Components using MediatR
- Identity (30+ handlers)
- Catalog (25+ handlers)
- Workflow (40+ handlers)
Phase 2: Convert Handlers to Services
// Before: Handler
public class GetAssetByIdHandler : IRequestHandler<GetAssetByIdQuery, Asset>
{
private readonly IAssetRepository _repository;
public async Task<Asset> Handle(GetAssetByIdQuery request, CancellationToken ct)
{
return await _repository.GetByIdAsync(request.Id, ct);
}
}
// After: Service method
public class AssetService
{
private readonly IAssetRepository _repository;
public async Task<Asset?> GetAssetByIdAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
}
Phase 3: Replace Pipeline Behaviors
Validation Behavior → Validation Filter:
// Before: Pipeline Behavior
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request, ...)
{
var validation = await _validator.ValidateAsync(request);
if (!validation.IsValid)
throw new ValidationException(validation.Errors);
return await next();
}
}
// After: Endpoint Filter
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterContext context, ...)
{
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
if (validator != null)
{
var validation = await validator.ValidateAsync((T)context.Arguments[0]!);
if (!validation.IsValid)
return TypedResults.ValidationProblem(validation.ToDictionary());
}
return await next(context);
}
}
Logging Behavior → Middleware:
// After: Logging Middleware
public class RequestLoggingMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
await _next(context);
if (stopwatch.ElapsedMilliseconds > 500)
{
_logger.LogWarning("Long running request: {Path} took {ElapsedMs}ms",
context.Request.Path, stopwatch.ElapsedMilliseconds);
}
}
}
Service Layer Pattern
public interface IAssetService
{
Task<PagedResult<Asset>> GetAssetsAsync(int page, int pageSize);
Task<Asset?> GetAssetByIdAsync(int id);
Task<Asset> CreateAssetAsync(CreateAssetRequest request);
Task<Asset> UpdateAssetAsync(int id, UpdateAssetRequest request);
Task DeleteAssetAsync(int id);
}
public class AssetService : IAssetService
{
private readonly CatalogDb _db;
private readonly ILogger<AssetService> _logger;
public async Task<Asset> CreateAssetAsync(CreateAssetRequest request)
{
_logger.LogInformation("Creating asset {Name}", request.Name);
var asset = new Asset
{
Name = request.Name,
TypeId = request.TypeId,
CreatedAt = DateTime.UtcNow
};
_db.Assets.Add(asset);
await _db.SaveChangesAsync();
_logger.LogInformation("Created asset {Id}", asset.Id);
return asset;
}
}
Direct Endpoint Implementation
public static class CatalogApi
{
public static void MapCatalogEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/catalog");
group.MapGet("/assets", GetAssets);
group.MapGet("/assets/{id}", GetAssetById);
group.MapPost("/assets", CreateAsset)
.AddEndpointFilter<ValidationFilter<CreateAssetRequest>>();
}
private static async Task<Results<Ok<PagedResult<Asset>>, BadRequest>> GetAssets(
[AsParameters] PaginationParams pagination,
[FromServices] IAssetService service)
{
var result = await service.GetAssetsAsync(pagination.Page, pagination.PageSize);
return TypedResults.Ok(result);
}
private static async Task<Results<Ok<Asset>, NotFound>> GetAssetById(
[FromRoute] int id,
[FromServices] IAssetService service)
{
var asset = await service.GetAssetByIdAsync(id);
return asset is not null
? TypedResults.Ok(asset)
: TypedResults.NotFound();
}
}
Testing Without MediatR
[Test]
public async Task CreateAsset_ValidRequest_ReturnsCreatedAsset()
{
// Arrange
var service = new AssetService(_mockDb, _mockLogger);
var request = new CreateAssetRequest("Laptop", 1);
// Act
var asset = await service.CreateAssetAsync(request);
// Assert
asset.Should().NotBeNull();
asset.Name.Should().Be("Laptop");
}
Removal Checklist
- Remove MediatR NuGet package
- Remove IRequest/IRequestHandler interfaces
- Convert handlers to service methods
- Remove IPipelineBehavior implementations
- Update endpoint registrations
- Update dependency injection
- Update tests
- Remove unused request/response classes
Timeline
- Q3 2024: Begin removal in new components
- Q4 2024: Migrate Identity component
- Q1 2025: Migrate Catalog and Workflow
- Q2 2025: Complete removal from all components
Related ADRs
- ADR-012: Phasing Out Enterprise Library Data (simplification theme)
- ADR-013: Minimal APIs with Static Classes (direct endpoint pattern)
- ADR-010: JWT Bearer Authentication Standard (simplification of auth)