Patrones: CQRS, Event Sourcing y Testing
En los capítulos anteriores, exploramos los fundamentos de Domain-Driven Design (DDD), desde el modelado estratégico y táctico hasta la implementación de microservicios con .NET 9. Ahora, abordamos patrones avanzados que potencian los sistemas complejos: CQRS (Command Query Responsibility Segregation), Event Sourcing y estrategias de testing. Como líder de KitsuneData Integral Solutions, he aplicado estos patrones para construir sistemas escalables y mantenibles, y en este capítulo te guiaré para implementarlos en .NET 9. Cubriremos los fundamentos de CQRS, la implementación de Event Sourcing, testing en sistemas DDD, integración con microservicios, y un ejemplo práctico que extiende el sistema de e-commerce de los capítulos anteriores.
6.1 CQRS: Separando comandos y consultas
CQRS (Command Query Responsibility Segregation) es un patrón que separa las operaciones de escritura (comandos) de las operaciones de lectura (consultas). En DDD, esto permite optimizar el modelo de dominio para comandos (lógica de negocio compleja) y usar modelos de lectura simplificados para consultas.
Beneficios
- Modelos especializados: El modelo de escritura refleja las reglas del dominio, mientras que el modelo de lectura está optimizado para rendimiento.
- Escalabilidad: Las consultas pueden usar bases de datos optimizadas (e.g., vistas materializadas o NoSQL).
- Flexibilidad: Permite diferentes tecnologías para comandos y consultas.
Implementación
Usaremos MediatR para manejar comandos y consultas en el contexto de “Gestión de Pedidos”.
Ejemplo de comando:
public record ConfirmarPedidoCommand(Guid PedidoId) : IRequest;
public class ConfirmarPedidoHandler : IRequestHandler<ConfirmarPedidoCommand>
{
private readonly IPedidoRepository _repository;
private readonly IDescuentoService _descuentoService;
private readonly IEventoPublisher _publisher;
public ConfirmarPedidoHandler(IPedidoRepository repository, IDescuentoService descuentoService, IEventoPublisher publisher)
{
_repository = repository;
_descuentoService = descuentoService;
_publisher = publisher;
}
public async Task Handle(ConfirmarPedidoCommand request, CancellationToken cancellationToken)
{
var pedido = await _repository.ObtenerPorIdAsync(request.PedidoId);
if (pedido == null)
throw new InvalidOperationException("Pedido no encontrado.");
pedido.Confirmar(_descuentoService);
await _repository.ActualizarAsync(pedido);
await _publisher.PublicarAsync(new PedidoConfirmadoEvent(pedido.Id, pedido.ClienteId, pedido.Total, DateTime.UtcNow));
}
}
Ejemplo de consulta:
public record ObtenerPedidoQuery(Guid PedidoId) : IRequest<PedidoDto>;
public record PedidoDto(Guid Id, Guid ClienteId, decimal Total, string Estado);
public class ObtenerPedidoHandler : IRequestHandler<ObtenerPedidoQuery, PedidoDto>
{
private readonly AppDbContext _context;
public ObtenerPedidoHandler(AppDbContext context)
{
_context = context;
}
public async Task<PedidoDto> Handle(ObtenerPedidoQuery request, CancellationToken cancellationToken)
{
return await _context.Pedidos
.Where(p => p.Id == request.PedidoId)
.Select(p => new PedidoDto(p.Id, p.ClienteId, p.Total, p.Estado))
.FirstOrDefaultAsync(cancellationToken);
}
}
6.2 Event Sourcing: Persistencia basada en eventos
Event Sourcing almacena el estado de un agregado como una secuencia de eventos, en lugar de guardar el estado actual. Cada evento representa un cambio en el dominio (e.g., PedidoCreado
, LineaAgregada
).
Beneficios
- Auditoría completa: Los eventos registran la historia del agregado.
- Flexibilidad: Permite reconstruir el estado o generar vistas alternativas.
- Integración con microservicios: Los eventos son ideales para comunicación asíncrona.
Implementación
Usaremos una tabla de eventos y un mecanismo para reconstruir el estado del agregado.
Ejemplo de evento y agregado:
public abstract record EventoDominio(Guid AgregadoId, DateTime FechaOcurrencia);
public record PedidoCreadoEvent(Guid PedidoId, Guid ClienteId, Direccion DireccionEnvio) : EventoDominio(PedidoId, DateTime.UtcNow);
public record LineaAgregadaEvent(Guid PedidoId, Guid ProductoId, int Cantidad, decimal Precio) : EventoDominio(PedidoId, DateTime.UtcNow);
public record PedidoConfirmadoEvent(Guid PedidoId, Guid ClienteId, decimal Total, DateTime FechaConfirmacion) : EventoDominio(PedidoId, DateTime.UtcNow);
public class Pedido
{
public Guid Id { get; private set; }
public Guid ClienteId { get; private set; }
public List<LineaPedido> Lineas { get; private set; } = new();
public decimal Total { get; private set; }
public string Estado { get; private set; }
public Direccion DireccionEnvio { get; private set; }
private Pedido() { } // Para EF Core
public static Pedido Reconstruir(IEnumerable<EventoDominio> eventos)
{
var pedido = new Pedido();
foreach (var evento in eventos)
{
pedido.Aplicar(evento);
}
return pedido;
}
private void Aplicar(EventoDominio evento)
{
switch (evento)
{
case PedidoCreadoEvent e:
Id = e.PedidoId;
ClienteId = e.ClienteId;
DireccionEnvio = e.DireccionEnvio;
Estado = "Creado";
break;
case LineaAgregadaEvent e:
Lineas.Add(new LineaPedido(e.ProductoId, e.Cantidad, e.Precio));
Total += e.Cantidad * e.Precio;
break;
case PedidoConfirmadoEvent e:
Estado = "Confirmado";
Total = e.Total;
break;
}
}
public void AgregarLinea(Guid productoId, int cantidad, decimal precio)
{
var evento = new LineaAgregadaEvent(Id, productoId, cantidad, precio);
Aplicar(evento);
}
public void Confirmar(IDescuentoService descuentoService)
{
if (Lineas.Count == 0)
throw new InvalidOperationException("El pedido no tiene líneas.");
Total = descuentoService.CalcularDescuento(ClienteId, Total);
var evento = new PedidoConfirmadoEvent(Id, ClienteId, Total, DateTime.UtcNow);
Aplicar(evento);
}
}
Persistencia de eventos:
public class EventoStore
{
private readonly AppDbContext _context;
public EventoStore(AppDbContext context)
{
_context = context;
}
public async Task GuardarEventoAsync(EventoDominio evento)
{
var eventoEntity = new EventoEntity
{
Id = Guid.NewGuid(),
AgregadoId = evento.AgregadoId,
Tipo = evento.GetType().Name,
Datos = JsonSerializer.Serialize(evento),
FechaOcurrencia = evento.FechaOcurrencia
};
await _context.Eventos.AddAsync(eventoEntity);
await _context.SaveChangesAsync();
}
public async Task<List<EventoDominio>> ObtenerEventosAsync(Guid agregadoId)
{
var eventos = await _context.Eventos
.Where(e => e.AgregadoId == agregadoId)
.OrderBy(e => e.FechaOcurrencia)
.ToListAsync();
return eventos.Select(e => JsonSerializer.Deserialize<EventoDominio>(e.Datos)).ToList();
}
}
public class EventoEntity
{
public Guid Id { get; set; }
public Guid AgregadoId { get; set; }
public string Tipo { get; set; }
public string Datos { get; set; }
public DateTime FechaOcurrencia { get; set; }
}
6.3 Testing en sistemas DDD
El testing en DDD se centra en validar las reglas de negocio y el comportamiento de los agregados. Usaremos xUnit y Moq para pruebas unitarias en .NET 9.
Estrategias de testing
- Pruebas de dominio: Verifican el comportamiento de entidades y agregados.
- Pruebas de comandos/consultas: Validan los manejadores de CQRS.
- Pruebas de eventos: Aseguran que los eventos se publiquen y procesen correctamente.
Ejemplo de prueba unitaria:
public class PedidoTests
{
private readonly Mock<IDescuentoService> _descuentoServiceMock;
private readonly Pedido _pedido;
public PedidoTests()
{
_descuentoServiceMock = new Mock<IDescuentoService>();
var direccion = new Direccion("Calle 123", "Ciudad", "12345");
_pedido = new Pedido(Guid.NewGuid(), direccion);
}
[Fact]
public void Confirmar_SinLineas_ArrojaExcepcion()
{
// Arrange
_descuentoServiceMock.Setup(s => s.CalcularDescuento(It.IsAny<Guid>(), It.IsAny<decimal>())).Returns(100m);
// Act & Assert
Assert.Throws<InvalidOperationException>(() => _pedido.Confirmar(_descuentoServiceMock.Object));
}
[Fact]
public void Confirmar_ConLineas_ActualizaEstadoYTotal()
{
// Arrange
_pedido.AgregarLinea(Guid.NewGuid(), 2, 50m);
_descuentoServiceMock.Setup(s => s.CalcularDescuento(It.IsAny<Guid>(), 100m)).Returns(90m);
// Act
_pedido.Confirmar(_descuentoServiceMock.Object);
// Assert
Assert.Equal("Confirmado", _pedido.Estado);
Assert.Equal(90m, _pedido.Total);
}
}
6.4 Integración con microservicios
CQRS y Event Sourcing encajan perfectamente con microservicios, ya que:
- CQRS permite separar la lógica de escritura y lectura en diferentes servicios o endpoints.
- Event Sourcing facilita la comunicación asíncrona mediante eventos, soportada por message brokers como RabbitMQ.
Ejemplo de microservicio con CQRS:
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddScoped<IPedidoRepository, PedidoRepository>();
builder.Services.AddScoped<IDescuentoService, DescuentoService>();
builder.Services.AddSingleton<IEventoPublisher, RabbitMqEventoPublisher>();
builder.Services.AddScoped<EventoStore>();
var app = builder.Build();
app.MapPost("/pedidos/confirmar", async (ConfirmarPedidoCommand command, IMediator mediator) =>
{
await mediator.Send(command);
return Results.Ok();
});
app.MapGet("/pedidos/{id}", async (Guid id, IMediator mediator) =>
{
var pedido = await mediator.Send(new ObtenerPedidoQuery(id));
return pedido != null ? Results.Ok(pedido) : Results.NotFound();
});
app.Run();
}
}
6.5 Ejemplo
Extendamos el microservicio de “Gestión de Pedidos” (Capítulo 5) con CQRS y Event Sourcing.
Escenario
- Contexto: Gestión de Pedidos.
- Funcionalidad: Crear y confirmar pedidos, persistir eventos con Event Sourcing, y usar CQRS para separar comandos y consultas.
- Integración: Publicar
PedidoConfirmadoEvent
para el microservicio de Facturación.
Código
// Modelos y eventos (como en secciones anteriores)
public record Direccion(string Calle, string Ciudad, string CodigoPostal)
{
public Direccion Validar()
{
if (string.IsNullOrEmpty(Calle) || string.IsNullOrEmpty(Ciudad) || string.IsNullOrEmpty(CodigoPostal))
throw new ArgumentException("Todos los campos de la dirección son requeridos.");
return this;
}
}
public record LineaPedido(Guid ProductoId, int Cantidad, decimal Precio)
{
public decimal Subtotal => Cantidad * Precio;
}
public abstract record EventoDominio(Guid AgregadoId, DateTime FechaOcurrencia);
public record PedidoCreadoEvent(Guid PedidoId, Guid ClienteId, Direccion DireccionEnvio) : EventoDominio(PedidoId, DateTime.UtcNow);
public record LineaAgregadaEvent(Guid PedidoId, Guid ProductoId, int Cantidad, decimal Precio) : EventoDominio(PedidoId, DateTime.UtcNow);
public record PedidoConfirmadoEvent(Guid PedidoId, Guid ClienteId, decimal Total, DateTime FechaConfirmacion) : EventoDominio(PedidoId, DateTime.UtcNow);
public class Pedido
{
public Guid Id { get; private set; }
public Guid ClienteId { get; private set; }
public List<LineaPedido> Lineas { get; private set; } = new();
public decimal Total { get; private set; }
public string Estado { get; private set; }
public Direccion DireccionEnvio { get; private set; }
private Pedido() { }
public static Pedido Reconstruir(IEnumerable<EventoDominio> eventos)
{
var pedido = new Pedido();
foreach (var evento in eventos)
{
pedido.Aplicar(evento);
}
return pedido;
}
private void Aplicar(EventoDominio evento)
{
switch (evento)
{
case PedidoCreadoEvent e:
Id = e.PedidoId;
ClienteId = e.ClienteId;
DireccionEnvio = e.DireccionEnvio;
Estado = "Creado";
break;
case LineaAgregadaEvent e:
Lineas.Add(new LineaPedido(e.ProductoId, e.Cantidad, e.Precio));
Total += e.Cantidad * e.Precio;
break;
case PedidoConfirmadoEvent e:
Estado = "Confirmado";
Total = e.Total;
break;
}
}
public void Crear(Guid clienteId, Direccion direccionEnvio)
{
var evento = new PedidoCreadoEvent(Guid.NewGuid(), clienteId, direccionEnvio);
Aplicar(evento);
}
public void AgregarLinea(Guid productoId, int cantidad, decimal precio)
{
var evento = new LineaAgregadaEvent(Id, productoId, cantidad, precio);
Aplicar(evento);
}
public void Confirmar(IDescuentoService descuentoService)
{
if (Lineas.Count == 0)
throw new InvalidOperationException("El pedido no tiene líneas.");
Total = descuentoService.CalcularDescuento(ClienteId, Total);
var evento = new PedidoConfirmadoEvent(Id, ClienteId, Total, DateTime.UtcNow);
Aplicar(evento);
}
}
// Repositorio para Event Sourcing
public interface IPedidoRepository
{
Task<Pedido> ObtenerPorIdAsync(Guid id);
Task GuardarAsync(Pedido pedido, IEnumerable<EventoDominio> eventos);
}
public class PedidoRepository : IPedidoRepository
{
private readonly EventoStore _eventoStore;
public PedidoRepository(EventoStore eventoStore)
{
_eventoStore = eventoStore;
}
public async Task<Pedido> ObtenerPorIdAsync(Guid id)
{
var eventos = await _eventoStore.ObtenerEventosAsync(id);
return eventos.Any() ? Pedido.Reconstruir(eventos) : null;
}
public async Task GuardarAsync(Pedido pedido, IEnumerable<EventoDominio> eventos)
{
foreach (var evento in eventos)
{
await _eventoStore.GuardarEventoAsync(evento);
}
}
}
// Configuración de EF Core para eventos
public class EventoStore
{
private readonly AppDbContext _context;
public EventoStore(AppDbContext context)
{
_context = context;
}
public async Task GuardarEventoAsync(EventoDominio evento)
{
var eventoEntity = new EventoEntity
{
Id = Guid.NewGuid(),
AgregadoId = evento.AgregadoId,
Tipo = evento.GetType().Name,
Datos = JsonSerializer.Serialize(evento),
FechaOcurrencia = evento.FechaOcurrencia
};
await _context.Eventos.AddAsync(eventoEntity);
await _context.SaveChangesAsync();
}
public async Task<List<EventoDominio>> ObtenerEventosAsync(Guid agregadoId)
{
var eventos = await _context.Eventos
.Where(e => e.AgregadoId == agregadoId)
.OrderBy(e => e.FechaOcurrencia)
.ToListAsync();
return eventos.Select(e => JsonSerializer.Deserialize<EventoDominio>(e.Datos)).ToList();
}
}
public class EventoEntity
{
public Guid Id { get; set; }
public Guid AgregadoId { get; set; }
public string Tipo { get; set; }
public string Datos { get; set; }
public DateTime FechaOcurrencia { get; set; }
}
public class AppDbContext : DbContext
{
public DbSet<EventoEntity> Eventos { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EventoEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.AgregadoId).IsRequired();
entity.Property(e => e.Tipo).IsRequired();
entity.Property(e => e.Datos).IsRequired();
entity.Property(e => e.FechaOcurrencia).IsRequired();
});
}
}
// Servicios y handlers
public interface IDescuentoService
{
decimal CalcularDescuento(Guid clienteId, decimal total);
}
public class DescuentoService : IDescuentoService
{
public decimal CalcularDescuento(Guid clienteId, decimal total)
{
// Simulación: 10% de descuento para clientes VIP
bool esClienteVip = false; // Consultar en implementación real
return esClienteVip ? total * 0.9m : total;
}
}
public record CrearPedidoCommand(Guid ClienteId, string Calle, string Ciudad, string CodigoPostal) : IRequest;
public class CrearPedidoHandler : IRequestHandler<CrearPedidoCommand>
{
private readonly IPedidoRepository _repository;
private readonly IEventoPublisher _publisher;
public CrearPedidoHandler(IPedidoRepository repository, IEventoPublisher publisher)
{
_repository = repository;
_publisher = publisher;
}
public async Task Handle(CrearPedidoCommand request, CancellationToken cancellationToken)
{
var direccion = new Direccion(request.Calle, request.Ciudad, request.CodigoPostal).Validar();
var pedido = new Pedido();
pedido.Crear(request.ClienteId, direccion);
await _repository.GuardarAsync(pedido, new[] { new PedidoCreadoEvent(pedido.Id, request.ClienteId, direccion) });
await _publisher.PublicarAsync(new PedidoCreadoEvent(pedido.Id, request.ClienteId, direccion));
}
}
// Configuración del microservicio
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddScoped<IPedidoRepository, PedidoRepository>();
builder.Services.AddScoped<IDescuentoService, DescuentoService>();
builder.Services.AddSingleton<IEventoPublisher, RabbitMqEventoPublisher>();
builder.Services.AddScoped<EventoStore>();
var app = builder.Build();
app.MapPost("/pedidos", async (CrearPedidoCommand command, IMediator mediator) =>
{
await mediator.Send(command);
return Results.Created();
});
app.MapPost("/pedidos/confirmar", async (ConfirmarPedidoCommand command, IMediator mediator) =>
{
await mediator.Send(command);
return Results.Ok();
});
app.MapGet("/pedidos/{id}", async (Guid id, IMediator mediator) =>
{
var pedido = await mediator.Send(new ObtenerPedidoQuery(id));
return pedido != null ? Results.Ok(pedido) : Results.NotFound();
});
app.Run();
}
}
Explicación
- CQRS: Usa
MediatR
para separar comandos (CrearPedidoCommand
,ConfirmarPedidoCommand
) y consultas (ObtenerPedidoQuery
). - Event Sourcing: Almacena eventos (
PedidoCreadoEvent
,LineaAgregadaEvent
,PedidoConfirmadoEvent
) en una tabla y reconstruye el estado del agregado. - Testing: Incluye pruebas unitarias para validar el comportamiento del agregado.
- Microservicio: Integra el modelo con ASP.NET Core 9, publicando eventos para Facturación mediante RabbitMQ.
- Persistencia: Usa EF Core 9 para almacenar eventos en una base de datos relacional.
En KitsuneData, hemos usado CQRS y Event Sourcing para sistemas que requieren auditoría y escalabilidad, como los de gestión empresarial. En el Capítulo 7, exploraremos casos prácticos avanzados. Este capítulo ha cubierto CQRS, Event Sourcing y testing en sistemas DDD con .NET 9. En el próximo capítulo, aplicaremos estos conceptos a casos prácticos más complejos.