Implementando CQRS con el paquete MediatR

Daniel Giraldo
12 min readMay 30, 2024

El capítulo de hoy será el primero de una serie en la cual aprenderemos acerca del uso de algunos paquetes que nos facilitan la implementación de principios y buenas prácticas en el desarrollo de Software usando el framework .NET, aunque puedes llevar la teoría y aplicarla en tu lenguaje favorito.

CQRS

Vamos a hablar sobre CQRS (Command Query Responsibility Segregation), en español Segregación de Responsabilidad de Comandos y Consultas. Pero, ¿qué es esto?

Cuando desarrollamos software solemos usar el mismo modelo de datos tanto para consultar como para actualizar la información en nuestras bases de datos. Si nuestra aplicación solo tiene operaciones CRUD básicas esto es perfecto y no tenemos ningún problema.​

Ahora vamos a otro escenario. Imaginemos una aplicación que realiza consultas de forma constante y cada una de estas retorna un DTO diferente. Realizar el mapeo de los objetos es algo que puede pasar a tener un nivel de complejidad alto y en lo que refiere a la escritura, el modelo puede implementar validaciones y lógica empresarial. Por lo tanto, al final podemos quedar con un modelo que tiene un gran nivel de complejidad y hace demasiadas cosas.​

CQRS nos permite separar los procesos de lectura y escritura en diferentes modelos, haciendo uso de comandos para actualizar datos y consultas para leerlos. Al hacer esto también cambiamos la forma en la que solemos procesar la información. Por lo general, cuando desarrollamos software nuestro código suele centrarse en los datos y no en las características y/o funcionalidades que este ofrece, con CQRS también cambiamos esto y pasamos a un modelo como el siguiente:

Los comandos que ejecutamos se deben centrar en las tareas en lugar de los datos. Por ejemplo: Imaginemos que tenemos una aplicación en la cual vendemos tickets para eventos, uno de los comandos que ejecutaremos sería el comando para realizar la reserva de un ticket, este comando deberíamos llamarlo “Reservar Ticket” en lugar de “Establecer Estado de Ticket en Reservado”. En caso de requerir una cancelación del ticket el comando debería llamarse “Cancelar Reserva Ticket” en lugar de “Estabelecer Estado de Ticket en Cancelado”. Recuerda: Siempre debemos centrarnos en las tareas, no en los datos.

Los comandos pueden ser ejecutados de forma síncrona o, si lo deseas también pueden almacenarse en una cola para su posterior procesamiento de forma asíncrona. Es elección tuya de acuerdo a las necesidades de tu proyecto.

Las consultas que realiza nuestra aplicación nunca modifican la base de datos.

Las respuestas de nuestra aplicación son un DTO que no debe encapsular ningún conocimiento de dominio.

​Teniendo claro lo que es CQRS ahora vamos a hablar sobre los beneficios que obtenemos al hacer uso de este patrón:

  • Esquema de datos Optimizado: Al tener las consultas y actualizaciones separadas, la lectura puede usar un esquema optimizado para consultas, mientras que la escritura un esquema optimizado para actualizaciones​.
  • Separación de Intereses: Al separar la lectura y escritura podemos obtener modelos más flexibles y fáciles de mantener. La mayor parte de la lógica empresarial compleja quedaría en el modelo de escritura, mientras que el modelo de lectura puede ser relativamente simple.​
  • Mantenibilidad: El tener modelos diferentes hace que la complejidad de nuestro sistema sea menor, por lo tanto será más sencilla de mantener.
  • Legibilidad del código: Centrarnos en las tareas antes que los datos hace que la lectura y navegación por el código sea más sencilla.
  • Extensibilidad: El separar los dos mundos nos ayuda a que cualquier cambio y/o evolución que implementemos sobre nuestro sistema se pueda llevar a cabo sin afectar a la otra parte.​
  • Simplificación del Modelo: Al momento de hacer la definición de los modelos que usará nuestro sistema, estos serán más sencillos pues se construirán acorde a la operación para la cual fueron creados.
  • Escalabilidad Independiente: Al separar los modelos, si estos se encuentran desacoplados, cada lado de CQRS (Command/Queries) puede escalar de forma independiente sin afectar al otro.​

Ya hablamos sobre CQRS, su definición, beneficios y problemas que busca resolver. Ahora, vamos con el segundo tema que es el paquete MediatR. Para hablar del paquete MediatR es necesario conocer el patrón de comportamiento Mediador, así que vamos a iniciar con su definición:

“Mediador es un patrón de diseño de comportamiento que te permite reducir las dependencias caóticas entre objetos. El patrón restringe las comunicaciones directas entre los objetos, forzándolos a colaborar únicamente a través de un objeto mediador.​” Refactoring Guru.

El patrón Mediador sugiere que debemos detener toda la comunicación directa entre los componentes que queremos hacer independientes entre sí. De esta forma los componentes deberán colaborar indirectamente haciendo uso de un mediador que se encargará de gestionar los llamados a los componentes adecuados. Logrando así, que los componentes dependan únicamente de una sola clase mediadora en lugar de que estén acoplados a muchos de sus colegas.

Mediator

Imagina cómo funciona la torre de control de un aeropuerto. Existe un controlador de tráfico aéreo que se encarga de mediar entre las diferentes aeronaves que se encuentran cerca de la pista, sin este controlador los pilotos deberían estar al tanto de lo que ocurre con cada una de las aeronaves, lo que podría convertirse en algo caótico y probablemente la estadística de accidentes aéreos incrementaría. Esto es lo que hace el patrón mediador, evitar que exista comunicación directa entre los componentes y de esta forma evitar el acoplamiento y las implicaciones que esto nos trae.

Ya tenemos claros los dos conceptos que vamos a poner en práctica en este capítulo. Así que manos a la obra y vamos a realizar una implementación de CQRS apoyándonos en el paquete MediatR.

El ejemplo que veremos a continuación solo es algo básico donde aprenderemos como es el funcionamiento del paquete MediatR y la implementación del patrón CQRS. Adicional, este código será el inicio de la serie de consejos y buenas prácticas que aprenderemos y lo iremos evolucionando durante cada capítulo de la serie.

Si deseas puedes hacer el ejercicio paso a paso o puedes descargar el código completo del siguiente link

  1. Crear solución y proyecto API.
  2. Crear controlador CustomersController.
[ApiController]
[Route("[controller]")]
public class CustomersController : ControllerBase
{
public CustomersController()
{
}

[HttpPost]
public async Task<IActionResult> CreateCustomer()
{
return Ok();
}
}

3. Agregar proyecto Application como Biblioteca de Clases.

4. Agregar paquetes Nuget MediatR y MediatR.Extensions.Microsoft.DependencyInjection al proyecto Application.

5. Crear estructura de carpeta para comandos y queries sobre los clientes. En este caso te enseño la estructura que suelo usar en mis proyectos, pero es tu decisión como estructuras esta parte.

6. Vamos a iniciar implementado el comando para crear un cliente en nuestra base de datos, para esto necesitamos crear una clase que será nuestro comando y otra clase que se encargará de manejar este comando. Adicional a esto como buena práctica y por separación de responsabilidades vamos a crear nuestro modelo de respuesta también al interior de esta carpeta, obteniendo la siguiente estructura:

La implementación al interior de cada una de las clases sería la siguiente:

public class CreateCustomerCommand : IRequest<CreateCustomerCommandResponse>
{
public string Identification { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
}

La clase implementa la interfaz IRequest<T> del paquete MediatR, donde T corresponde a la respuesta que dará el comando que ejecutaremos. En caso de que el comando que vamos a ejecutar no retorne ninguna respuesta deberíamos implementar la interfaz IRequest.

public class CreateCustomerCommandResponse
{
public CreateCustomerCommandResponse(string identification, string firstName, string lastName, string email, DateTime birthDate)
{
Identification = identification;
FirstName = firstName;
LastName = lastName;
Email = email;
BirthDate = birthDate;
}

public string Identification { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
}

La clase anterior será nuestro DTO de respuesta.

public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CreateCustomerCommandResponse>
{
public async Task<CreateCustomerCommandResponse> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
return new CreateCustomerCommandResponse();
}
}

La clase anterior implementa la interfaz IRequestHandler<TRequest, TReponse> del paquete MediatR. Donde TRequest corresponde al comando o query que vamos a ejecutar y, TResponse corresponde a la respuesta que retornará la ejecución del comando o query. Con lo anterior tendremos lista la implementación del comando para crear un cliente, pero aún nos falta implementar la lógica para la creación del cliente, esto lo haremos más adelante.

7. Registrar Mediator en el contenedor de dependencias. Para esto creamos la clase ApplicationDI la cual tiene un método de extensión sobre la interfaz IServiceCollection y de esta forma inyectar todas las dependencias que le conciernen al proyecto Application.

public static class ApplicationDI
{
public static IServiceCollection AddApplicationLayer(this IServiceCollection services)
{
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(CreateCustomerCommand).Assembly);
});
return services;
}
}

8. Creamos el proyecto Domain como Biblioteca de clases en el cual tendremos nuestras entidades e interfaces.

A continuación veremos como queda estructurado nuestro proyecto Domain.

En el proyecto tenemos una carpeta para nuestras entidades, una carpeta interfaces donde ubicaremos las interfaces de nuestros repositorios y de nuestros casos de uso. En este primer ejemplo tenemos el caso de uso para la creación de un usuario. Recordemos que en este primer capítulo solo abordaremos el patrón CQRS y el uso del paquete MediatR que nos ayuda en la implementación de este. Por lo que nuestro dominio no tendrá lógica alguna, esta parte la abordaremos en los próximos capítulos que podrás encontrar en mi blog.

Las clases e interfaces quedan de la siguiente forma:

public class Customer
{
public string Id { get; set; }
public string Identification { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
}
public interface ICustomersRepository
{
Task<IEnumerable<Customer>> GetAll();
Task<Customer> GetById(string id);
Task<Customer> Add(Customer customer);
Task Delete(string id);
}
public interface ICreateCustomerUseCase
{
Task<Customer> Execute(Customer customer);
}
public class CreateCustomerUseCase : ICreateCustomerUseCase
{
private readonly ICustomersRepository _customersRepository;
public CreateCustomerUseCase(ICustomersRepository customersRepository)
{
_customersRepository = customersRepository;
}

public async Task<Customer> Execute(Customer customer)
{
var result = await _customersRepository.Add(customer);
return result;
}
}
public static class DomainDI
{
public static IServiceCollection AddDomainLayer(this IServiceCollection services)
{
services.AddTransient<ICreateCustomerUseCase, CreateCustomerUseCase>();
return services;
}
}

Al igual que en el proyecto Application, en Domain también tenemos una clase propia que tiene un método de extensión sobre la interfaz IServiceCollection para inyectar los componentes que le conciernen al proyecto Domain.

9. Creamos un proyecto nuevo como Biblioteca de Clases en el cual vamos a tener una clase que simulara nuestro repositorio de clientes.

Agregamos referencia al proyecto Domain para hacer uso de la interfaz que implementará nuestro repositorio de clientes.

Esta será la estructura de nuestro proyecto

La clase CustomersRepository quedaría de la siguiente forma:

public class CustomersRepository : ICustomersRepository
{
private List<Customer> _customers;
public CustomersRepository()
{
_customers = new List<Customer>();
}

public async Task<Customer> Add(Customer customer)
{
_customers.Add(customer);
return customer;
}

public async Task Delete(string identification)
{
_customers.Remove(_customers.First(x=> x.Identification == identification));
}

public async Task<IEnumerable<Customer>> GetAll()
{
return _customers;
}

public async Task<Customer> GetById(string identification)
{
return _customers.First(x => x.Identification == identification);
}
}
public static class InfrastructureDI
{
public static IServiceCollection AddInfrastructureLayer(this IServiceCollection services)
{
services.AddSingleton<ICustomersRepository, CustomersRepository>();
return services;
}
}

Al igual que en los proyectos Application y Domain, en Infrastructure también tenemos una clase con un método de extensión para inyectar las dependencias que le conciernen a este proyecto.

10. En nuestro proyecto Application agregamos referencia hacía el proyecto Domain.

11. Vamos a hacer uso del patrón Result para obtener el resultado de la ejecución de nuestro comando. Para esto creamos una clase con el nombre Result en el proyecto Application de la siguiente forma:

public class Result<T>
{
public Result(T value, bool isSuccess, string error)
{
Value = value;
IsSuccess = isSuccess;
Error = error;
}

public T Value { get; }
public bool IsSuccess { get; }
public string Error { get; }

public static Result<T> Success(T value) => new Result<T>(value, true, null);
public static Result<T> Failure(string error) => new Result<T>(default, false, error);
}

Una vez creada la clase vamos a hacer un refactor sobre nuestro comando y el manejador del comando para cambiar el tipo de dato que retornará la ejecución de este. Una vez hecho el refactor deberíamos tener el siguiente código

public class CreateCustomerCommand : IRequest<Result<CreateCustomerCommandResponse>>
{
public string Identification { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
}
public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, Result<CreateCustomerCommandResponse>>
{
public async Task<Result<CreateCustomerCommandResponse>> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
return Result<CreateCustomerCommandResponse>.Success(new CreateCustomerCommandResponse());
}
}

12. Validamos que el comando a ejecutar sea correcto y cumpla con el contrato de nuestra API

public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, Result<CreateCustomerCommandResponse>>
{
public async Task<Result<CreateCustomerCommandResponse>> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
StringBuilder errors;
if (ModelIsValid(request, out errors))
{
return Result<CreateCustomerCommandResponse>.Success(new CreateCustomerCommandResponse());
}

return Result<CreateCustomerCommandResponse>.Failure(errors.ToString());
}

private bool ModelIsValid(CreateCustomerCommand createCustomerCommand, out StringBuilder errors)
{
errors = new StringBuilder();
if (string.IsNullOrEmpty(createCustomerCommand.FirstName))
errors.Append("First Name must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.LastName))
errors.Append("Last Name must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.Identification))
errors.Append("Identification must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.Email))
errors.Append("Email must not be emtpy.\n");
if (!Regex.IsMatch(createCustomerCommand.Email, @"^([a-zA-Z0–9_\-\.]+)@([a-zA-Z0–9_\-\.]+)\.([a-zA-Z]{2,5})$"))
errors.Append("Email format is invalid.\n");

return errors.Length == 0;
}
}

El código implementado puede mejorar, las validaciones se pueden hacer de una mejor forma, pero eso lo abordaremos en el siguiente capítulo de la serie.

13. Inyectamos el caso de uso para crear usuario en nuestro comando y el código quedaría de la siguiente forma:

public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, Result<CreateCustomerCommandResponse>>
{
private readonly ICreateCustomerUseCase _createCustomerUseCase;
public CreateCustomerCommandHandler(ICreateCustomerUseCase createCustomerUseCase)
{
_createCustomerUseCase = createCustomerUseCase;
}

public async Task<Result<CreateCustomerCommandResponse>> Handle(CreateCustomerCommand request, CancellationToken cancellationToken)
{
StringBuilder errors;
if (ModelIsValid(request, out errors))
{
var customer = new Customer(request.Identification, request.FirstName, request.LastName, request.Email, request.BirthDate);
var result = await _createCustomerUseCase.Execute(customer);
return Result<CreateCustomerCommandResponse>.Success(MapToResponse(result));
}

return Result<CreateCustomerCommandResponse>.Failure(errors.ToString());
}

private bool ModelIsValid(CreateCustomerCommand createCustomerCommand, out StringBuilder errors)
{
errors = new StringBuilder();
if (string.IsNullOrEmpty(createCustomerCommand.FirstName))
errors.Append("First Name must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.LastName))
errors.Append("Last Name must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.Identification))
errors.Append("Identification must not be emtpy.\n");
if (string.IsNullOrEmpty(createCustomerCommand.Email))
errors.Append("Email must not be emtpy.\n");
if (!Regex.IsMatch(createCustomerCommand.Email, @"^([a-zA-Z0–9_\-\.]+)@([a-zA-Z0–9_\-\.]+)\.([a-zA-Z]{2,5})$"))
errors.Append("Email format is invalid.\n");

return errors.Length == 0;
}

private CreateCustomerCommandResponse MapToResponse(Customer customer)
{
return new CreateCustomerCommandResponse(result.Identification, result.FirstName, result.LastName, result.Email, result.BirthDate);
}
}

14. Agregar referencia al proyecto Application, Domain e Infrastructure en el proyecto API.

15. Inyectar interfaz IMediator del paquete MediatR en el controlador y realizamos la implementación para el llamado al comando de creación de cliente. El código quedaría de la siguiente forma.

[ApiController]
[Route("[controller]")]
public class CustomerController : ControllerBase
{
private readonly IMediator _mediator;
public CustomerController(IMediator mediator)
{
_mediator = mediator;
}

[HttpPost]
public async Task<IActionResult> CreateCustomer([FromBody] CreateCustomerCommand createCustomerCommand)
{
var result = await _mediator.Send(createCustomerCommand);
if (result.IsSuccess)
return Ok(result.Value);
return BadRequest(result.Error);
}
}

16. Por último vamos a registrar las dependencias de nuestros proyectos en la aplicación. Para esto vamos a hacer uso de los métodos de extensión creados en cada uno de los proyectos para la interfaz IServiceCollection, agregando las siguientes líneas sobre la clase Program.cs.

builder.Services.AddApplicationLayer();
builder.Services.AddDomainLayer();
builder.Services.AddInfrastructureLayer();

Ahora podremos ejecutar la aplicación y ver la ejecución de nuestro comando.

Puedes descargar el código del siguiente link, este contiene el ejemplo completo con los diferentes comandos y consultas que ejecutaremos sobre los clientes.

Recuerda que este capítulo es el primero de una serie en la cual aprenderemos acerca del uso de algunos paquetes que nos facilitan la implementación de principios y buenas prácticas en el desarrollo de Software usando el framework .NET (Aunque puedes llevar la teoría y aplicarla en tu lenguaje favorito). Hoy abordamos CQRS y el uso del paquete MediatR que nos ayuda a implementar este patrón. En próximos capítulos aprenderemos acerca de validación de modelos, manejo de excepciones, endpoins independientes, automatización de validaciones, arquitectura de corte vertical y otras prácticas que nos van a ayudar en nuestro crecimiento profesional.

--

--