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 aquel post es un tema diferente, se une totalmente porque el mediador nos permite una facilidad 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 idempotente.
- Queries: Consultan el estado del dominio, operación idempotente.
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 máster 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 aprendizaje sea muy alta. Cuando un junior quiera modificar una funcionalidad, claro que dará miedo romper algo, ya que toda esa funcionalidad está fuertemente acoplada 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 cuándo refactorizar. Si tenemos un command que hace x tarea, pero en otro comando también lo hace, tal vez es tiempo de pensar sobre otros 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 interactúan entre sí. 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í interactúen entre sí. 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 (específicamente, 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 mías 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 implementar 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 típicamente 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>();
}
Para crear la base de datos y su migración inicial, ejecutamos el siguiente comando:
dotnet ef migrations add FirstMigration -o Infrastructure/Persistence/Migrations
dotnet ef database update
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 práctica 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 cómo se llevará acabo la tarea.
En este caso, GetProductQuery
****es un IRequest<T>
que lo que representa en sí, 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 quisiéramos 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 cómo 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 aprendizaje, 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.
Top comments (13)
Hola Isaac, gracias por escribir el tutorial, me ha servido para mantener un proyecto.
De igual manera me gustaría dar mis observaciones sobre su artículo, que en realidad no es para generar una discusión o algo por el estilo.
Citaré algunos párrafos:
Los inconvenientes que mencionan los párrafos no es un problema en sí del patrón de Repositorio o de agente de servicio, sino de las malas prácticas que se aplican al proyecto. Sí sigue los principios de ingeniería como el de dependencias explícitas y de responsabilidad única, se puede lograr tener una clase altamente cohesiva y de bajo acoplamiento.
Un ejemplo simple obtenido del código de nopCommerce:
CustomerRegistrationService.cs
La clase
CustomerRegistrationService
está saturada, tiene 20 dependencias! Solo mirando el constructor es una señal que la clase debe ser dividida por partes, para así cumplir con el principio de responsabilidad única, sin embargo, para lograrlo no es necesario crear un método de servicio por clase:ValidateCustomerHandler.Handle
,RegisterCustomerHandler.Handle
, etc.CQRS tiene sus ventajas. El problema es cuando se aplica de manera tan estricta y rígida, por lo que esto conduce a tener un montón de archivos de clases por cada acción que realice un punto final (endpoint):
GetProductsQueryHandler
,EditProductsQueryHandler
, etc. La desventaja de hacerlo así es que cuando se necesite realizar alguna modificación (o estudio) a una funcionalidad, se debe de acceder a muchas clases (es como moverte entre clases). Para Dave es una desventaja, tal vez para otros no.Me gusto mucho esta implementación de CQRS: github.com/Odonno/cqrs-dotnet-core... (no es tan estricta).
Este comentario es solo mi punto de vista. Saludos!
Revisando el repositorio de Odonno realmente yo creo ya son opiniones de cada quien, pero aquí vuelve a ser lo mismo que busco evitar, aunque sigas el single responsability, la clase
ParkingCommandHandler
la vas a tener que partir para no tener una clase gigante como laCustomerRegistrationService
.Tener varios métodos
Handle
en una clase también me causa conflicto, ya que la forma en la que navego el código, no se me haría práctico, aunque estoy seguro que es algo trivial a lo que te puedes acostumbrar.Te recomiendo esta charla de Jimmy Bogard, esa es una razón por la que decido hacer una clase por feature, a veces hay features muy pesados que tienen mucha lógica, tener todo junto en ese feature me resulta práctico y fácil de entender. Entiendo que puedes aplicar mil principios y formas para limpiar el
CommandHandler
y que no se convierta en un "big ball of mud" al final como dices, son opiniones jeje.Saludos ya por última vez!
Sí, utilizar una clase por feature cuando la lógica es demasiada compleja, me parece bien. Sin embargo, he visto proyectos que por cada feature "simple" crean una clase y apenas tiene cinco líneas de código (o peor aún, una sola línea), entonces, al menos para mí, me parece tedioso estar moviéndome entre tantos archivos que al final tienen una lógica demasiada simple.
Y otro detalle que he visto en estos proyectos, es que usan nombres de clases que representan acciones, por ejemplo
GetProductsByIdHandler
, al menos para mí lo veo confuso, ya que estoy acostumbrado a nombrar clases como sustantivos (tal vez documentando las clases puede ser de ayuda para saber cuál es su intención).De igual manera, gracias por responder Issac.
Saludos!
Volviendo a leer tu comentario:
Realmente es lo contrario, un archivo tiene de responsabilidad un feature y para cambiar ese feature hay que irnos solamente a ese archivo, no a uno gigante como un repositorio (como lo menciona el post)
Tal vez dependiendo del equipo y el tipo de proyecto, se deben de evaluar la estructura del proyecto, pero al final siempre se deben de seguir los principios de ingeniería como lo mencionas.
Saludos nuevamente!
Muchas gracias por tus comentarios, son totalmente válidos.
Tal vez, la razón por la que a mi me funciona tener cientos de archivos como lo mencionas, es por mi uso de navegación en el editor.
Ejem. En VS Code
CTRL + P
y luego mi búsqueda nada explícita, ejem.GetProdQH
haciendo referencia aGetProductsQueryHandler
. El buscador lo hace muy bien y no es necesario escribir toda la palabra, tal vez esa es la razón por la que a mi o en mi equipo no nos resulta un problema tener muchos archivos.Gracias por compartir nuevamente. Saludos!
reddit.com/r/csharp/comments/rxxg5.... Según esto usar mediatr es 52x más ineficiente que usar un servicio directamente,y creo que para un web API está métrica es importante,a veces escribir menos código no significa más eficiencia a coste de rendimiento
Mira este vídeo de Nick y los comentarios de zshazz (en el hilo de reddit) son interesantes y están acorde al vídeo.
Es mentira que sea 52 veces más lento.
Lo que sí es real, sí existe una degradación del performance, pero en ciertos escenarios es donde vas a "sentirlo". Por los beneficios que agrega MediatR y el tipo de aplicación, para mi sigue siendo una opción 99% recomendada. No todos programamos el siguiente Netflix que requiere 1,000 microservicios, pero sí programamos para estar listos a adaptarnos a eso.
Gracias por tu comentario, siempre es bueno indagar y cuestionar, por que eso nos lleva a investigar más. Saludos.
Excelente aporte. Esperare con paciencia la parte 2. Gracias por compartir
Gracias, ya está arriba la parte 2. Saludos
Excelente articulo, tuve que agregar en el constructor de MyAppDbContext esta instruccion :
public MyAppDbContext(DbContextOptions options) : base(options)
{
Database.EnsureCreated();
}
Saludos
Tienes razón, no expliqué la parte donde hice la migración y actualización de la BD...
Gracias!
En el CreateProductCommandHandler , como seria la modificacion si necesito agregar una lista de objectos al CreateProductCommand , en lugar de un solo objeto.
Gracias
Pues tendrías que incluirlo en el
IRequest
como un listado, una idea es:Y en el Handler, iteras la creación del producto.
Pueden haber más formas, pero algo así lo haría yo
Saludos!!