This content originally appeared on DEV Community and was authored by Isaac Ojeda
Introducción
En este post veremos de un tema que ya he hablado en otras ocasiones, pero en este caso quisiera profundizar más y empezar una serie de posts que nos permitan conocer distintos patrones de diseño al momento de desarrollar servicios web.
Estoy hablando de CQRS, un patrón que se ha convertido en mi forma default de diseñar sistemas en los últimos años.
CQRS tiene sus ventajas y seguro sus desventajas, que hasta ahora no me ha dolido ninguna.
Espero que este post te sea de utilidad. Te recuerdo que siempre subo el código a mi github y podrás ver este código aquí.
¿Qué es CQRS?
En posts anteriores mencioné un par de razones de por qué es muy buena idea hacer uso de CQRS, especialmente si estamos usando librerías como MediatR. Aunque lo que vimos en aquél post es un tema diferente, se une totalmente porque el mediador nos permite una fácilidad en muchos temas al diseñas sistemas. De igual forma, veremos un repaso de CQRS.
Command Query Responsibility Segregation es lo que CQRS dice en sus iniciales. Es un patrón de diseño que se ha vuelto muy popular en los últimos años. La idea detrás de CQRS es partir lógicamente el flujo de nuestra aplicación en dos flujos distintos:
- Commands: Modifican el estado del dominio, no idemponente.
- Queries: Consultan el estado del dominio, operación idemponente.
Si pensamos en un CRUD, los comandos (los que cambian el estado) serán Create, Update y Delete. Los Quieries, pues la lectura Read.
La siguiente imagen muestra la idea principal de cómo funciona:
Como podemos ver, la aplicación simplemente se parte en dos conceptos principales, queries y commands. La idea principal de CQRS es también partir ese datastore en dos (uno master y otro replicado) para leer de uno y escribir en el otro, pero la idea de partirlo de una forma lógica funciona muy bien en el diseño del sistema aunque se use una misma base de datos (que sin problema se podría implementar el uso de bases de datos físicamente separadas).
¿Qué problema se intenta resolver?
El diseño tradicional de aplicaciones en “n-capas” suelen dividir en tres capas: UI, Business Logic, Data Store.
En sus inicios esto no tiene ningún problema, pero el problema está en el mantenimiento y la falta de flexibilidad de agregar nueva funcionalidad, de depuración y entre otras cosas.
En sistemas n-capas se cuenta con Repositorios enormes, donde se encuentran todas las operaciones que puedes hacer en un entity. También se suelen contar con Servicios de la misma forma, gigantes.
La segregación de responsabilidades es una cuestión importante al mantenimiento de un sistema. Modificar una funcionalidad no debería de afectar a cosas totalmente externas. Tener una clase ProductsService
donde se encuentre todo lo que hace el sistema sobre los productos, se convertirá en un problema sí o sí cuando este sistema no pare de crecer, ingresen nuevos miembros al equipo y la curva de aprendisaje sea muy alta. Cuando un junior quiera modificar una funcionalidad, claro que dará miedo romper algo, ya que toda esa funcionalidad está fuertemente acomplada en el servicio/repositorio.
Separar en Queries y Commands y mejor aún, en Vertical Slices (Features) permitirá tener un código bien separado, agregar funcionalidad significará agregar más Queries/Commands y no modificar Services o Repositories gigantes.
También que sea testeable de una forma más sencilla, un servicio puede tener dependencias para las distintas operaciones que hace sobre un Entity. Ese servicio necesitará todos esos mocks para probar x o y. Un Command solo tendrá lo que necesita para funcionar, y nada más, se encuentra totalmente encapsulado de otra funcionalidad, modificar otro comando no debe afectar a otros.
Claro está, que debemos de saber cuando refactorizar. Si tenemos un command que hace x tarea, pero en otro comando también lo hace, tal vez es tiempo de pensar sobre otro tipos de patrones (Strategy, decorators, etc) y refactorizar. También debemos de encontrar balance con DRY (Don’t Repeat Yourself) sin ignorar el Single Responsability (es un lio ¿no? con el tiempo será más fácil, te lo prometo).
Mediator Pattern
El patrón mediador simplemente es la definición de un objeto que encapsula como otros objetos interactuan entre si. En lugar de tener dos o más objetos que dependen directamente de otros objetos, solo toman dependencia directa de un “mediador” y este se encarga de gestionar las interacciones entre objetos:
Como podemos ver SomeService
manda un mensaje al mediador, y el mediador manda a llamar otros servicios para que hagan algo según el mensaje recibido. SomeService
no conoce nada sobre los otros servicios que hacen algo con su solicitud, solo le dice al mediador que “necesita que se haga algo”.
La razón por lo que el patrón mediador es muy útil, es por la misma razón que usamos patrones como Inversion Of Control. Nos permite totalmente desacoplar componentes pero que aun así interactuen entre si. Lo menos que tenga que considerar un componente para funcionar, es más fácil desarrollarlo, evolucionarlo y testearlo.
MediatR nos facilita implementar CQRS y el patrón mediador
MediatR es una implementación del mediador que ocurre todo in-process (en la misma aplicación), y totalmente, nos ayuda a crear sistemas con CQRS. Toda la comunicación entre el usuario y la persistencia ocurre por medio de MediatR.
El término *in-proces*s es una importante limitación aquí. Como .NET maneja toda interacción entre objetos en un mismo proceso, no es apropiado si queremos separar los Queries y Commands a nivel aplicación (es decir, tener sistemas separados).
Para este tipo de escenarios es mejor utilizar algún Message Broker, como ya lo vimos en este post que escribí.
Implementando CQRS en ASP.NET Core
La idea de utilizar CQRS en ASP.NET Core (especificamente, una Web API) es delegar la responsabilidad de procesar cada Request a un Handler y no al Controller (y aparte todo lo que vimos anteriormente arriba).
¿Por qué? Podemos tener varias razones, las mias son, que todo el procesamiento de los requests de la API no dependan de los Controllers y lo delega a alguien en el “Application Core” (pensando en Clean architecture o Vertical Slice).
Sin problema alguno, en .NET 7, por performance podría empezar a utilizar Minimal APIs. Ya que los controllers no realizan tarea alguna (solo recibir la solicitud) y podemos hacer ese tipo de cambios sin problemas.
Para implemenar CQRS en asp.net core utilizando MediatR (y de ejemplo, una base de datos SQLite) utilizaremos los siguientes paquetes en un proyecto Web API (dotnet new webapi
):
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.4.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Podemos ignorar todo el código ejemplo que viene en la plantilla (las clases Weather y eso) y trabajaremos con la siguiente estructura en un mismo proyecto (de igual forma, te recomiendo revisar el código):
Controllers/
Domain/
Features/
├─ Products/
Infrastructure/
├─ Persistence/
Siempre trato de hacerlo siguiendo los conceptos que tipicamente usaríamos en una clean architecture, por ahora no importa si lo hago todo en un solo proyecto, con el tiempo decidirás como dividir tus proyectos (dos o más proyectos en una misma solución, etc).
Domain
Aquí realmente no hay nada que explicar, simplemente usaremos una clase Product
para hacer este ejemplo.
namespace MediatrValidationExample.Domain;
public class Product
{
public int ProductId { get; set; }
public string Description { get; set; } = default!;
public double Price { get; set; }
}
Nota 💡: Aquí usamos el operador
default!
simplemente para tener un string “inicializado” y le decimos al compilador que nunca seránull
(lo cual es una reverenda mentira, el default de un string esnull
. Son las malas prácticas que les enseño.
Infrastructure → Persistence
Como siempre, utilizaremos Entity Framework Core para la persistencia
using MediatrValidationExample.Domain;
using Microsoft.EntityFrameworkCore;
namespace MediatrValidationExample.Infrastructure.Persistence;
public class MyAppDbContext : DbContext
{
public MyAppDbContext(DbContextOptions<MyAppDbContext> options) : base(options)
{ }
public DbSet<Product> Products => Set<Product>();
}
Features → Products → Queries
Este folder representa el Application Core, aquí irán los Queries y Commands que requiera la Web Api. Podemos empezar con el ejemplo simple de consultar Producto(s).
La forma en que les mostraré como hago los Queries y Commands es una practica que acabo de adoptar del Vertical Slice Architecture. Si quieres saber más sobre el tema, también he escrito sobre ello.
En resumen, la idea es poner todo lo que se necesite en un solo archivo (El request, handler, validators, mappers, models, etc) y como comento en el post, refactorizar si es necesario (igual es otro tema, pero ya queda a tu criterio como hacerlo).
using MediatR;
using MediatrValidationExample.Infrastructure.Persistence;
namespace MediatrValidationExample.Features.Products.Queries;
public class GetProductQuery : IRequest<GetProductQueryResponse>
{
public int ProductId { get; set; }
}
public class GetProductQueryHandler : IRequestHandler<GetProductQuery, GetProductQueryResponse>
{
private readonly MyAppDbContext _context;
public GetProductQueryHandler(MyAppDbContext context)
{
_context = context;
}
public async Task<GetProductQueryResponse> Handle(GetProductQuery request, CancellationToken cancellationToken)
{
var product = await _context.Products.FindAsync(request.ProductId);
return new GetProductQueryResponse
{
Description = product.Description,
ProductId = product.ProductId,
Price = product.Price
};
}
}
public class GetProductQueryResponse
{
public int ProductId { get; set; }
public string Description { get; set; } = default!;
public double Price { get; set; }
}
Lo importante aquí es poner atención en la interfaz IRequest<T>
y IRequestHandler<T>
.
IRequest<T>
es la solicitud o mensaje que indica la tarea a realizar, solicitada por SomeService y dirigida a n Handlers (como lo veíamos en la imagen arriba).
Es decir, el mediador va a tomar el IRequest<T>
y se lo mandará a los handlers registrados. Estos handlers saben del mensaje que pueden recibir y ellos saben como se llevará acabo la tarea.
En este caso, GetProductQuery
****es un IRequest<T>
que lo que representa en si, es buscar un producto. IRequest<T>
incluye un genérico para poder especificar el tipo de objeto que va a regresar (ya que pues, es un query, estamos consultando el estado del dominio).
En otros tiempos, lo que se hubiera hecho es un ProductsService o ProductsRepository con un método GetById. En este caso, la clase representa la operación a realizar, no un método más de una clase con más métodos.
Esto es lo que me encanta de este patrón, tendremos muchos archivos y carpetas, eso sí, pero archivos pequeños y fáciles de buscar gracias a los poderosos editores de texto / IDEs.
GetProductQueryHandler
es el handler del mismo Query definido arriba. Como están en el mismo archivo, podríamos decir que el Request y Handler están acoplados entre sí, pero aislados de lo demás.
Agregar funcionalidad o testearla simplemente involucra lo que está en este archivo y nada más.
using MediatR;
using MediatrValidationExample.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MediatrValidationExample.Features.Products.Queries;
public class GetProductsQuery : IRequest<List<GetProductsQueryResponse>>
{
}
public class GetProductsQueryHandler : IRequestHandler<GetProductsQuery, List<GetProductsQueryResponse>>
{
private readonly MyAppDbContext _context;
public GetProductsQueryHandler(MyAppDbContext context)
{
_context = context;
}
public Task<List<GetProductsQueryResponse>> Handle(GetProductsQuery request, CancellationToken cancellationToken) =>
_context.Products
.AsNoTracking()
.Select(s => new GetProductsQueryResponse
{
ProductId = s.ProductId,
Description = s.Description,
Price = s.Price
})
.ToListAsync();
}
public class GetProductsQueryResponse
{
public int ProductId { get; set; }
public string Description { get; set; } = default!;
public double Price { get; set; }
}
En este otro ejemplo, el IRequest está vacío, pero si quisieramos buscar productos, agregar paginación, ordenamiento, etc. Se haría en esta clase GetProductsQuery
, ya que representa el request que recibe la API (lo veremos en el controller).
Todos los Queries deberían de incluir el método AsNoTracking
, por la razón misma que son Queries y no necesitan actualizar ningún estado de los Entities.
Features → Products → Commands
En los comandos ahora sí se actualizarán los entities, en post posteriores enseñaré como agregar validaciones, decoradores y entre otras cosas que son bien fáciles de hacer gracias a otras librerías como FluentValidation y la misma ya usada MediatR.
using MediatR;
using MediatrValidationExample.Domain;
using MediatrValidationExample.Infrastructure.Persistence;
namespace MediatrValidationExample.Features.Products.Commands;
public class CreateProductCommand : IRequest
{
public string Description { get; set; } = default!;
public double Price { get; set; }
}
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand>
{
private readonly MyAppDbContext _context;
public CreateProductCommandHandler(MyAppDbContext context)
{
_context = context;
}
public async Task<Unit> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var newProduct = new Product
{
Description = request.Description,
Price = request.Price
};
_context.Products.Add(newProduct);
await _context.SaveChangesAsync();
return Unit.Value;
}
}
Aquí lo único que necesitamos del request, es el nombre del producto que queremos registrar y su precio. Se sigue usando la interfaz de MediatR IRequest
, solo que ahora sin un tipo genérico, porque los comandos generalmente no regresan información.
Controllers
Dentro de controllers, por fin haremos uso del mediador. Será de la siguiente manera:
using MediatR;
using MediatrValidationExample.Features.Products.Commands;
using MediatrValidationExample.Features.Products.Queries;
using Microsoft.AspNetCore.Mvc;
namespace MediatrValidationExample.Controllers;
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IMediator _mediator;
public ProductsController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Consulta los productos
/// </summary>
/// <returns></returns>
[HttpGet]
public Task<List<GetProductsQueryResponse>> GetProducts() => _mediator.Send(new GetProductsQuery());
/// <summary>
/// Crea un producto nuevo
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command)
{
await _mediator.Send(command);
return Ok();
}
/// <summary>
/// Consulta un producto por su ID
/// </summary>
/// <param name="query"></param>
/// <returns></returns>
[HttpGet("{ProductId}")]
public Task<GetProductQueryResponse> GetProductById([FromRoute] GetProductQuery query) =>
_mediator.Send(query);
}
Por Dependency Injection se solicita el mediador con la interfaz IMediator
. Una vez teniendo el IRequest
correspondiente inicializado, simplemente se lo mandamos al mediador y el determinará el handler(s) que deben de ejecutar la solicitud.
CreateProduct
el IRequest
(aka command) se recibe desde el Body del request (ya que es una clase POCO, se puede recibir y serializar sin ningún problema).
En GetProductyById
el IRequest
(aka Query) se obtiene del Path del URL. Aquí sí es importante que en el segmento del Path se llame igual que la propiedad para que haga match.
En GetProducts
se inicializa manualmente, ya que no estamos recibiendo nada desde la solicitud, pero podría hacerse con un [FromQuery]
sin ningún problema para recibir parámetros adicionales.
Wrapping Up
Para poder correr todo esto, tenemos que configurar dependencias y todo lo necesario para que todo lo que acabamos de hacer funcione (tal vez, por aquí deberías de empezar para ir probando mientras escribes tus queries gg)
En Program.cs hacemos lo siguiente (lo pongo completo porque es pequeño)
using MediatR;
using MediatrValidationExample.Domain;
using MediatrValidationExample.Infrastructure.Persistence;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddControllers();
builder.Services.AddMediatR(Assembly.GetExecutingAssembly());
builder.Services.AddSqlite<MyAppDbContext>(builder.Configuration.GetConnectionString("Default"));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
await SeedProducts();
app.Run();
async Task SeedProducts()
{
using var scope = app.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<MyAppDbContext>();
if (!context.Products.Any())
{
context.Products.AddRange(new List<Product>
{
new Product
{
Description = "Product 01",
Price = 16000
},
new Product
{
Description = "Product 02",
Price = 52200
}
});
await context.SaveChangesAsync();
}
}
Aquí suceden varias cosas que hay que comentar:
-
AddEndpointsApiExplorer
yAddSwaggerGen
son configuración default ya incluida en la plantilla. Sabemos que esto habilita la generación de documentos que describen la API usando OpenAPI. -
AddControllers
agrega lo necesario para poder usarControllerBase
en una API (no incluye razor ni nada que tenga que ver con Views) -
AddMediatR
agrega el mediador y busca todos losIRequest
yIRequestHandlers
que nuestro assembly tenga (o sea, en nuestro proyecto). -
AddSqlite
pues agrega elDbContext
utilizando el proveedor SQLite -
SeedProducts
crea dos productos de ejemplos para que podamos jugar con la SwaggerUI y hacer pruebas.
En este punto ya puedes correr la aplicación e ingresar a /swagger para que puedas ver su funcionamiento.
Conclusión
Hemos aprendido como configurar CQRS utilizando MediatR en un proyecto en ASP.NET Core Web API.
Vimos como podemos encapsular cada funcionalidad de nuestra API en archivos individuales, cada uno representando un Query o Command.
Utilizar CQRS tiene sus ventajas y también podría tener sus desventajas, aunque el sistema esté bien dividido en Features, Queries y Commands. Entre más crezca, cada miembro nuevo del equipo obviamente tendrá su curva de aprendisaje, y si nunca utilizó este tipo de patrones, aumentará su curva. Pero es para un bien mayor.
Diseñar sistemas mantenibles debe de ser también una meta de cada Developer / Solution Architect, ya que haces un sistema y probablemente alguien en el futuro tendrá que mantenerlo. Hacer ese proceso menos doloroso es lo mejor que se puede hacer.
Esta división de conceptos nos ha ayudado mucho en los últimos proyectos desarrollados en mi equipo, agregar funcionalidad o modificarla no debe de ser un dolor de cabeza.
Referencias
- CQRS Validation Pipeline with MediatR and FluentValidation - Code Maze (code-maze.com)
- CQRS and MediatR in ASP.NET Core - Code Maze (code-maze.com)
- Implementing the microservice application layer using the Web API | Microsoft Docs
This content originally appeared on DEV Community and was authored by Isaac Ojeda
Isaac Ojeda | Sciencx (2022-03-31T00:02:47+00:00) [Parte 1] CQRS y MediatR: Implementando CQRS en ASP.NET.. Retrieved from https://www.scien.cx/2022/03/31/parte-1-cqrs-y-mediatr-implementando-cqrs-en-asp-net/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.