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
  • 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)