Cinta transportadora industrial con piezas metálicas; en el centro, un desviador neumático separa el flujo en dos vías. En una, un punzón marca la pieza con chispas; en la otra, el haz violeta de un sensor incide sobre la pieza sin tocarla. Penumbra industrial

CQRS con MediatR en .NET: separar lectura y escritura

Actualización (2026): MediatR cambió su modelo de licencia en julio de 2025. El patrón CQRS no depende de esta librería —puedes aplicarlo igual con tus propias interfaces—, pero si vas a empezar un proyecto hoy, lee antes el post sobre alternativas a MediatR antes de decidir qué usar.

Llevo años separando lectura y escritura en proyectos .NET, y la pregunta que más me hacen los equipos que empiezan con esto es siempre la misma: ¿de verdad hace falta tanta ceremonia para algo que podría ser un método de un servicio? La respuesta corta es que depende del proyecto. Este post explica cómo implemento CQRS con MediatR en .NET, con criterio de cuándo compensa y cuándo es una capa de más.

Qué es CQRS y qué resuelve de verdad

CQRS (Command Query Responsibility Segregation) parte de una idea simple: separar las operaciones que cambian el estado del sistema (comandos) de las que solo consultan datos (queries). No es una tecnología ni una librería concreta — es una decisión de diseño que puedes aplicar con clases sencillas o con herramientas que te ayuden a organizarla.

El problema que resuelve es el de los servicios «todoterreno»: una clase PedidoService que crece indefinidamente porque ahí van a parar tanto la lógica de crear un pedido como la de listar pedidos con cinco filtros distintos, cada uno con sus propias reglas de rendimiento y de seguridad. Con el tiempo, cambiar la lógica de lectura obliga a entender también la de escritura, aunque no tengan nada que ver entre sí.

Por qué uso MediatR para organizarlo

MediatR es hoy la librería estándar en el ecosistema .NET para implementar el patrón mediador: en vez de que un controlador llame directamente a un servicio, envía un comando o una consulta a través de un mediador, que se encarga de encontrar el handler correcto y ejecutarlo. Es gratuita, madura, y la mayoría de proyectos .NET con los que me he encontrado que aplican CQRS ya la usan o la conocen, lo que facilita mucho incorporar a alguien nuevo al equipo.

Podrías implementar el mismo patrón sin ninguna librería, con tus propias interfaces ICommandHandler y IQueryHandler. Lo he hecho en algún proyecto pequeño. Pero en cuanto el número de comandos y queries crece, MediatR ahorra el código de fontanería (registrar handlers, resolver el correcto en tiempo de ejecución) que de otro modo escribirías tú mismo, sin aportar nada distinto a lo que ya resuelve una librería probada.

Cómo organizo CQRS con MediatR en .NET

La estructura que uso separa cada comando y cada query en su propia clase, con su propio handler:

// Comando: cambia el estado del sistema
public record CrearPedidoCommand(string ClienteId, List<LineaPedido> Lineas)
    : IRequest<Guid>;

public class CrearPedidoCommandHandler : IRequestHandler<CrearPedidoCommand, Guid>
{
    private readonly IPedidoRepository _repositorio;

    public CrearPedidoCommandHandler(IPedidoRepository repositorio)
    {
        _repositorio = repositorio;
    }

    public async Task<Guid> Handle(CrearPedidoCommand comando, CancellationToken cancellationToken)
    {
        var pedido = Pedido.Crear(comando.ClienteId, comando.Lineas);
        await _repositorio.GuardarAsync(pedido, cancellationToken);
        return pedido.Id;
    }
}

// Query: solo lee, nunca cambia estado
public record ObtenerPedidosPorClienteQuery(string ClienteId)
    : IRequest<List<PedidoResumenDto>>;

public class ObtenerPedidosPorClienteQueryHandler
    : IRequestHandler<ObtenerPedidosPorClienteQuery, List<PedidoResumenDto>>
{
    private readonly IDbConnection _conexion;

    public ObtenerPedidosPorClienteQueryHandler(IDbConnection conexion)
    {
        _conexion = conexion;
    }

    public async Task<List<PedidoResumenDto>> Handle(
        ObtenerPedidosPorClienteQuery consulta, CancellationToken cancellationToken)
    {
        // Las queries pueden usar acceso a datos más directo y optimizado
        // para lectura, sin pasar por el mismo camino que la escritura.
        const string sql = "SELECT Id, Fecha, Total FROM Pedidos WHERE ClienteId = @ClienteId";
        var resultado = await _conexion.QueryAsync<PedidoResumenDto>(sql, new { consulta.ClienteId });
        return resultado.ToList();
    }
}

La diferencia importante no es solo de nombres: los comandos suelen pasar por el modelo de dominio completo, con sus invariantes y validaciones, porque cambian estado. Las queries pueden saltarse ese camino y leer directamente lo que necesitan, optimizado para lectura, sin cargar entidades completas que no van a modificarse.

Pipeline behaviors: dónde meto la lógica transversal

Una de las razones por las que sigo prefiriendo MediatR frente a interfaces propias es el soporte de pipeline behaviors: código que se ejecuta antes y después de cada comando o query, sin que cada handler tenga que repetirlo.

public class ValidacionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validadores;

    public ValidacionBehavior(IEnumerable<IValidator<TRequest>> validadores)
    {
        _validadores = validadores;
    }

    public async Task<TResponse> Handle(
        TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        foreach (var validador in _validadores)
        {
            var resultado = await validador.ValidateAsync(request, cancellationToken);
            if (!resultado.IsValid)
                throw new ValidacionException(resultado.Errors);
        }

        return await next();
    }
}

Con esto, la validación de un comando se escribe una sola vez como comportamiento del pipeline, y cada handler nuevo la recibe automáticamente sin tener que acordarse de llamarla. Lo mismo aplica a logging, medición de tiempos, o control de transacciones — código que, sin este mecanismo, tiende a copiarse y pegarse en cada handler hasta que alguien se olvida de hacerlo en uno nuevo.

Cómo pruebo comandos y queries por separado

Uno de los beneficios menos comentados de organizar así el código es lo que simplifica el testing. Un handler de comando tiene una entrada clara (el comando) y una salida clara (lo que devuelve o el estado que cambia), con pocas dependencias explícitas — normalmente un repositorio. Probar que «al enviar este comando, el pedido queda creado con estos datos» no exige levantar el sistema completo ni pasar por capas intermedias que no tienen nada que ver con esa lógica.

public class CrearPedidoCommandHandlerTests
{
    [Fact]
    public async Task Handle_DebeGuardarElPedidoConLasLineasRecibidas()
    {
        var repositorioFalso = new PedidoRepositoryEnMemoria();
        var handler = new CrearPedidoCommandHandler(repositorioFalso);

        var comando = new CrearPedidoCommand("cliente-1", new List<LineaPedido>
        {
            new LineaPedido("producto-1", cantidad: 2)
        });

        var id = await handler.Handle(comando, CancellationToken.None);

        var pedidoGuardado = await repositorioFalso.ObtenerPorIdAsync(id);
        Assert.NotNull(pedidoGuardado);
        Assert.Single(pedidoGuardado.Lineas);
    }
}

Las queries se prueban igual de directo: dado un estado conocido de datos, la consulta debe devolver exactamente lo esperado. No hay lógica de negocio que simular ni efectos secundarios que verificar — solo comprobar que la lectura es correcta.

Qué CQRS con MediatR en .NET no resuelve por sí solo

Separar comandos y queries no arregla un dominio mal modelado. Si las reglas de negocio de tus comandos no están bien pensadas, tenerlas en handlers separados en vez de en un servicio grande no las hace correctas — solo las organiza mejor. La arquitectura ordena el código que ya sabes que quieres escribir; no sustituye a entender el problema de negocio primero.

Tampoco resuelve, por sí solo, problemas de rendimiento si el cuello de botella está en la base de datos y no en cómo está organizado el código de la aplicación. Antes de introducir CQRS pensando que va a arreglar una consulta lenta, vale la pena comprobar si el problema real es un índice que falta o una consulta mal escrita — un problema que ninguna reorganización de handlers va a resolver.

Cuándo compensa esta separación

Mi criterio para decidir si aplicar CQRS con MediatR en .NET, después de usarlo en varios proyectos, es el siguiente:

  • Compensa cuando la lectura y la escritura tienen necesidades de rendimiento o de modelo muy distintas — por ejemplo, escrituras que pasan por reglas de negocio complejas y lecturas que necesitan responder rápido con datos ya casi listos para mostrar.
  • Compensa cuando el número de casos de uso es alto y necesitas un lugar predecible donde encontrar cada uno, sin que todos vivan mezclados en la misma clase de servicio.
  • No compensa en un CRUD simple donde leer y escribir son, en la práctica, la misma operación con un verbo distinto. Ahí, CQRS con MediatR en .NET añade una capa de indirección — comando, handler, mediador — para resolver un problema que no existe todavía.

Un error que he visto repetirse

El error más habitual es tratar CQRS como sinónimo de «bases de datos separadas para lectura y escritura». Eso es una variante avanzada del patrón (con su propia complejidad de sincronización), no el punto de partida. La mayoría de proyectos se benefician de separar comandos y queries en el código, usando la misma base de datos para los dos, mucho antes de necesitar infraestructuras separadas. Empezar por la versión compleja porque «así se hace en los artículos avanzados» es forzar una solución antes de tener el problema que la justifica.

MediatR frente a alternativas sin librería

Hoy, MediatR es la opción estándar del ecosistema .NET para este patrón: gratuita, con buena documentación, y con una comunidad amplia detrás. Si tu equipo ya conoce MediatR de otros proyectos, usarla reduce la curva de entrada de cualquiera que se incorpore. Si prefieres no añadir una dependencia externa, el patrón se puede implementar igual con interfaces propias — la organización del código es la misma, cambia solo quién resuelve qué handler ejecutar.

Lo que me llevo de aplicar esto en producción

No elijo CQRS con MediatR en .NET porque sea la tendencia del momento. Lo elijo cuando el proyecto tiene comandos y queries con necesidades reales distintas, y cuando el equipo va a crecer lo suficiente como para que un lugar predecible donde buscar cada caso de uso ahorre más tiempo del que cuesta aprender el patrón. Si el proyecto es pequeño y va a seguir siéndolo, un servicio bien escrito sin ninguna de estas capas puede ser, sencillamente, la decisión correcta — y no pasa nada por reconocerlo antes de añadir complejidad que nadie va a agradecer.

¿Estás construyendo un proyecto .NET nuevo y dudando cómo organizar comandos y queries desde el principio? En Desarrollo .NET a medida tomo esta decisión de arquitectura antes de escribir la primera línea de código.

Fuente externa citada: CQRS pattern — Azure Architecture Center, Microsoft Learn — la documentación oficial de Microsoft sobre el patrón, independiente de qué librería se use para implementarlo.