Learnixo
Back to blog
AI Systemsintermediate

Manual CQRS — Commands, Queries, and Handlers Without MediatR

How to implement CQRS manually in Clean Architecture without MediatR: command and query records, typed handlers, DI-based dispatch, and why skipping the mediator keeps the code simpler and more navigable.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETCQRSCommandsQueriesNo MediatR
Share:𝕏

Why Manual CQRS Without MediatR

MediatR is a popular in-process mediator. It works well. But it introduces indirection: _mediator.Send(new CreatePatientCommand(...)) — you cannot Ctrl+Click to the handler. The dispatch path is hidden.

Manual CQRS is simpler:

With MediatR:    Controller → ISender.Send → [MediatR resolves handler] → Handler
Manual CQRS:     Controller → CreatePatientCommandHandler.Handle → Handler

✓ Direct dependency — navigable with any IDE
✓ No pipeline behavior magic unless you add it explicitly
✓ No runtime registration errors from missing handler registrations
✓ Easier to read for developers who don't know MediatR

The CQRS Contracts

Commands change state. Queries read state. Neither should do both.

C#
// Application/Common/ICommand.cs
namespace SystemForge.Application.Common;

public interface ICommand<TResponse> { }

public interface IQuery<TResponse> { }

// Application/Common/ICommandHandler.cs
public interface ICommandHandler<TCommand, TResponse>
    where TCommand : ICommand<TResponse>
{
    Task<Result<TResponse>> Handle(TCommand command, CancellationToken ct);
}

// Application/Common/IQueryHandler.cs
public interface IQueryHandler<TQuery, TResponse>
    where TQuery : IQuery<TResponse>
{
    Task<Result<TResponse>> Handle(TQuery query, CancellationToken ct);
}

A Full Command

C#
// Application/Patients/Commands/CreatePatient/CreatePatientCommand.cs
public sealed record CreatePatientCommand(
    string Name,
    DateOnly DateOfBirth,
    string MRN) : ICommand<PatientId>;

// Application/Patients/Commands/CreatePatient/CreatePatientCommandHandler.cs
public sealed class CreatePatientCommandHandler
    : ICommandHandler<CreatePatientCommand, PatientId>
{
    private readonly IPatientRepository _patients;
    private readonly IUnitOfWork _unitOfWork;

    public CreatePatientCommandHandler(
        IPatientRepository patients,
        IUnitOfWork unitOfWork)
    {
        _patients   = patients;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<PatientId>> Handle(
        CreatePatientCommand command,
        CancellationToken ct)
    {
        if (await _patients.ExistsByMRNAsync(command.MRN, ct))
            return Result.Failure<PatientId>(PatientErrors.MRNAlreadyExists);

        var result = Patient.Create(command.Name, command.DateOfBirth, command.MRN);
        if (result.IsFailure)
            return Result.Failure<PatientId>(result.Error);

        await _patients.AddAsync(result.Value, ct);
        await _unitOfWork.SaveChangesAsync(ct);
        return Result.Success(result.Value.Id);
    }
}

A Full Query

C#
// Application/Patients/Queries/GetPatient/GetPatientQuery.cs
public sealed record GetPatientQuery(PatientId PatientId) : IQuery<PatientResponse>;

// Application/Patients/Queries/GetPatient/GetPatientQueryHandler.cs
public sealed class GetPatientQueryHandler
    : IQueryHandler<GetPatientQuery, PatientResponse>
{
    private readonly IPatientRepository _patients;

    public GetPatientQueryHandler(IPatientRepository patients)
        => _patients = patients;

    public async Task<Result<PatientResponse>> Handle(
        GetPatientQuery query,
        CancellationToken ct)
    {
        var patient = await _patients.GetByIdAsync(query.PatientId, ct);

        if (patient is null)
            return Result.Failure<PatientResponse>(PatientErrors.NotFound);

        return Result.Success(MapToResponse(patient));
    }

    private static PatientResponse MapToResponse(Patient patient) => new(
        patient.Id.Value,
        patient.Name,
        patient.DateOfBirth,
        patient.MRN,
        patient.IsActive,
        patient.Prescriptions
            .Select(p => new PrescriptionSummary(
                p.Id.Value,
                p.MedicationCode.Value,
                p.Dosage.ToString(),
                p.IsActive))
            .ToList());
}

Multiple Commands for the Same Entity

C#
// Application/Patients/Commands/DeactivatePatient/DeactivatePatientCommand.cs
public sealed record DeactivatePatientCommand(PatientId PatientId) : ICommand<Unit>;

public sealed class DeactivatePatientCommandHandler
    : ICommandHandler<DeactivatePatientCommand, Unit>
{
    private readonly IPatientRepository _patients;
    private readonly IUnitOfWork _unitOfWork;

    public DeactivatePatientCommandHandler(
        IPatientRepository patients,
        IUnitOfWork unitOfWork)
    {
        _patients   = patients;
        _unitOfWork = unitOfWork;
    }

    public async Task<Result<Unit>> Handle(
        DeactivatePatientCommand command,
        CancellationToken ct)
    {
        var patient = await _patients.GetByIdAsync(command.PatientId, ct);
        if (patient is null)
            return Result.Failure<Unit>(PatientErrors.NotFound);

        patient.Deactivate();
        await _unitOfWork.SaveChangesAsync(ct);
        return Result.Success(Unit.Value);
    }
}

// Application/Common/Unit.cs
public sealed record Unit
{
    public static readonly Unit Value = new();
    private Unit() { }
}

DI Registration

C#
// Application/DependencyInjection.cs
public static IServiceCollection AddApplication(this IServiceCollection services)
{
    var assembly = typeof(AssemblyReference).Assembly;

    // Register all command handlers
    var commandHandlers = assembly.GetTypes()
        .Where(t => !t.IsAbstract && !t.IsInterface)
        .Where(t => t.GetInterfaces()
            .Any(i => i.IsGenericType &&
                      i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>)));

    foreach (var handlerType in commandHandlers)
    {
        var interfaceType = handlerType.GetInterfaces()
            .First(i => i.IsGenericType &&
                        i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>));
        services.AddScoped(interfaceType, handlerType);
        services.AddScoped(handlerType);   // also register concrete for direct injection
    }

    // Register all query handlers
    var queryHandlers = assembly.GetTypes()
        .Where(t => !t.IsAbstract && !t.IsInterface)
        .Where(t => t.GetInterfaces()
            .Any(i => i.IsGenericType &&
                      i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>)));

    foreach (var handlerType in queryHandlers)
    {
        var interfaceType = handlerType.GetInterfaces()
            .First(i => i.IsGenericType &&
                        i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>));
        services.AddScoped(interfaceType, handlerType);
        services.AddScoped(handlerType);
    }

    return services;
}

Using Handlers in Controllers

C#
// Api/Controllers/PatientsController.cs
[ApiController]
[Route("api/patients")]
[Authorize]
public sealed class PatientsController : ControllerBase
{
    private readonly CreatePatientCommandHandler _create;
    private readonly DeactivatePatientCommandHandler _deactivate;
    private readonly GetPatientQueryHandler _getPatient;

    public PatientsController(
        CreatePatientCommandHandler create,
        DeactivatePatientCommandHandler deactivate,
        GetPatientQueryHandler getPatient)
    {
        _create     = create;
        _deactivate = deactivate;
        _getPatient = getPatient;
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        CreatePatientRequest request, CancellationToken ct)
    {
        var command = new CreatePatientCommand(request.Name, request.DateOfBirth, request.MRN);
        var result = await _create.Handle(command, ct);
        return result.Match<IActionResult>(
            id  => CreatedAtAction(nameof(GetById), new { id = id.Value }, new { id = id.Value }),
            err => Problem(detail: err.Description, statusCode: 400));
    }

    [HttpGet("{id:guid}")]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var query = new GetPatientQuery(new PatientId(id));
        var result = await _getPatient.Handle(query, ct);
        return result.Match<IActionResult>(Ok, err => NotFound(err.Description));
    }

    [HttpDelete("{id:guid}")]
    public async Task<IActionResult> Deactivate(Guid id, CancellationToken ct)
    {
        var command = new DeactivatePatientCommand(new PatientId(id));
        var result = await _deactivate.Handle(command, ct);
        return result.Match<IActionResult>(_ => NoContent(), err => Problem(err.Description));
    }
}

Adding Cross-Cutting Concerns Without MediatR

Without a pipeline, you add cross-cutting concerns by decorating handlers:

C#
// Application/Common/LoggingCommandHandlerDecorator.cs
public sealed class LoggingCommandHandlerDecorator<TCommand, TResponse>
    : ICommandHandler<TCommand, TResponse>
    where TCommand : ICommand<TResponse>
{
    private readonly ICommandHandler<TCommand, TResponse> _inner;
    private readonly ILogger<LoggingCommandHandlerDecorator<TCommand, TResponse>> _logger;

    public LoggingCommandHandlerDecorator(
        ICommandHandler<TCommand, TResponse> inner,
        ILogger<LoggingCommandHandlerDecorator<TCommand, TResponse>> logger)
    {
        _inner  = inner;
        _logger = logger;
    }

    public async Task<Result<TResponse>> Handle(TCommand command, CancellationToken ct)
    {
        _logger.LogInformation("Handling {Command}", typeof(TCommand).Name);
        var result = await _inner.Handle(command, ct);
        if (result.IsFailure)
            _logger.LogWarning("Command {Command} failed: {Error}", typeof(TCommand).Name, result.Error.Code);
        return result;
    }
}

Key Takeaway

Manual CQRS is not simpler because it avoids a NuGet package — it is simpler because every dependency is explicit and navigable. When you Ctrl+Click _create.Handle(command, ct) in the controller, you land directly in CreatePatientCommandHandler. There is no registration magic, no pipeline behavior to search for, no handler discovery that silently fails. Simplicity in architecture is not about fewer features; it is about fewer surprises.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.