Casos de Estudio y Mejores Prácticas
En los capítulos anteriores, exploramos los fundamentos de Domain-Driven Design (DDD), desde el modelado estratégico y táctico hasta patrones avanzados como CQRS y Event Sourcing, todo implementado con .NET 9. Ahora, consolidamos este conocimiento con casos de estudio prácticos y mejores prácticas derivadas de proyectos reales. Como líder de KitsuneData Integral Solutions, he aplicado DDD en sistemas complejos para transformar procesos empresariales, y en este capítulo te guiaré para que hagas lo mismo. Cubriremos un caso de estudio detallado basado en un sistema de gestión de inventarios, mejores prácticas para DDD, integración con microservicios, y lecciones aprendidas de implementaciones reales.
7.1 Caso de estudio: Sistema de gestión de inventarios
Este caso de estudio extiende el sistema de e-commerce de capítulos anteriores, enfocándose en el contexto acotado de Gestión de Inventarios, que interactúa con el contexto de Gestión de Pedidos. Usaremos DDD táctico, CQRS, y microservicios para modelar e implementar este sistema.
Descripción del dominio
- Contexto acotado: Gestión de Inventarios.
- Funcionalidades:
- Registrar productos y su stock.
- Actualizar el stock cuando se confirma un pedido (integración con el contexto de Pedidos).
- Publicar eventos como
StockActualizadoEvent
para notificar cambios.
- Reglas de negocio:
- No se puede confirmar un pedido si no hay stock suficiente.
- El stock debe actualizarse atómicamente para evitar inconsistencias.
- Integración: Recibe eventos
PedidoConfirmadoEvent
del microservicio de Pedidos y publicaStockActualizadoEvent
.
Modelo de dominio
public class Producto
{
public Guid Id { get; private set; }
public string Nombre { get; private set; }
public int Stock { get; private set; }
private Producto() { } // Para EF Core
public Producto(string nombre, int stock)
{
Id = Guid.NewGuid();
Nombre = nombre ?? throw new ArgumentNullException(nameof(nombre));
Stock = stock >= 0 ? stock : throw new ArgumentException("El stock no puede ser negativo.");
}
public void ReducirStock(int cantidad, Guid pedidoId)
{
if (cantidad <= 0)
throw new ArgumentException("La cantidad debe ser mayor a cero.");
if (Stock < cantidad)
throw new InvalidOperationException($"Stock insuficiente para el producto {Nombre} en el pedido {pedidoId}.");
Stock -= cantidad;
}
}
public record StockActualizadoEvent(Guid ProductoId, int NuevoStock, Guid PedidoId, DateTime FechaActualizacion);
Integración con CQRS
Usamos MediatR para manejar comandos y consultas, integrando el contexto de Inventarios con Pedidos.
Comando para actualizar stock:
public record ActualizarStockCommand(Guid PedidoId, Dictionary<Guid, int> Productos) : IRequest;
public class ActualizarStockHandler : IRequestHandler<ActualizarStockCommand>
{
private readonly IProductoRepository _repository;
private readonly IEventoPublisher _publisher;
public ActualizarStockHandler(IProductoRepository repository, IEventoPublisher publisher)
{
_repository = repository;
_publisher = publisher;
}
public async Task Handle(ActualizarStockCommand request, CancellationToken cancellationToken)
{
foreach (var (productoId, cantidad) in request.Productos)
{
var producto = await _repository.ObtenerPorIdAsync(productoId);
if (producto == null)
throw new InvalidOperationException($"Producto {productoId} no encontrado.");
producto.ReducirStock(cantidad, request.PedidoId);
await _repository.ActualizarAsync(producto);
await _publisher.PublicarAsync(new StockActualizadoEvent(producto.Id, producto.Stock, request.PedidoId, DateTime.UtcNow));
}
}
}
Consulta para obtener stock:
public record ObtenerStockQuery(Guid ProductoId) : IRequest<StockDto>;
public record StockDto(Guid ProductoId, string Nombre, int Stock);
public class ObtenerStockHandler : IRequestHandler<ObtenerStockQuery, StockDto>
{
private readonly AppDbContext _context;
public ObtenerStockHandler(AppDbContext context)
{
_context = context;
}
public async Task<StockDto> Handle(ObtenerStockQuery request, CancellationToken cancellationToken)
{
return await _context.Productos
.Where(p => p.Id == request.ProductoId)
.Select(p => new StockDto(p.Id, p.Nombre, p.Stock))
.FirstOrDefaultAsync(cancellationToken);
}
}
Persistencia
public interface IProductoRepository
{
Task<Producto> ObtenerPorIdAsync(Guid id);
Task AgregarAsync(Producto producto);
Task ActualizarAsync(Producto producto);
}
public class ProductoRepository : IProductoRepository
{
private readonly AppDbContext _context;
public ProductoRepository(AppDbContext context)
{
_context = context;
}
public async Task<Producto> ObtenerPorIdAsync(Guid id)
{
return await _context.Productos.FindAsync(id);
}
public async Task AgregarAsync(Producto producto)
{
await _context.Productos.AddAsync(producto);
await _context.SaveChangesAsync();
}
public async Task ActualizarAsync(Producto producto)
{
_context.Productos.Update(producto);
await _context.SaveChangesAsync();
}
}
public class AppDbContext : DbContext
{
public DbSet<Producto> Productos { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Producto>(entity =>
{
entity.HasKey(p => p.Id);
entity.Property(p => p.Nombre).IsRequired();
entity.Property(p => p.Stock).IsRequired();
});
}
}
Microservicio de Inventarios
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<IProductoRepository, ProductoRepository>();
builder.Services.AddSingleton<IEventoPublisher, RabbitMqEventoPublisher>();
var app = builder.Build();
app.MapPost("/inventarios/actualizar", async (ActualizarStockCommand command, IMediator mediator) =>
{
await mediator.Send(command);
return Results.Ok();
});
app.MapGet("/inventarios/{id}", async (Guid id, IMediator mediator) =>
{
var stock = await mediator.Send(new ObtenerStockQuery(id));
return stock != null ? Results.Ok(stock) : Results.NotFound();
});
app.Run();
}
}
Integración con Pedidos
El microservicio de Inventarios se suscribe al evento PedidoConfirmadoEvent
del microservicio de Pedidos (Capítulo 5) para actualizar el stock.
Suscripción a eventos:
public class PedidoConfirmadoEventHandler
{
private readonly IMediator _mediator;
public PedidoConfirmadoEventHandler(IMediator mediator)
{
_mediator = mediator;
}
public async Task Handle(PedidoConfirmadoEvent evento)
{
// Simulación: Obtener productos del pedido (en un sistema real, consultar el repositorio)
var productos = new Dictionary<Guid, int> { { Guid.NewGuid(), 2 } }; // Ejemplo
var command = new ActualizarStockCommand(evento.PedidoId, productos);
await _mediator.Send(command);
}
}
Diagrama PlantUML
7.2 Mejores prácticas para DDD en .NET 9
Basado en mi experiencia en KitsuneData Integral Solutions, aquí presento mejores prácticas para aplicar DDD con .NET 9:
-
Prioriza el lenguaje ubicuo:
- Realiza talleres de Event Storming para definir términos con expertos del dominio.
- Usa nombres de clases, métodos y variables que reflejen el vocabulario del negocio.
-
Mantén agregados pequeños:
- Diseña agregados con una sola entidad raíz y un mínimo de dependencias.
- Por ejemplo, el agregado
Producto
solo incluyeStock
yNombre
, evitando referencias a otros agregados.
-
Usa CQRS para escalabilidad:
- Separa modelos de escritura y lectura para optimizar rendimiento y mantenimiento.
- Implementa consultas ligeras con vistas materializadas en bases NoSQL si es necesario.
-
Aplica Event Sourcing selectivamente:
- Úsalo en contextos que requieran auditoría o reconstrucción de estados (e.g., inventarios).
- Combina con bases relacionales para lecturas rápidas.
-
Automatiza pruebas:
- Escribe pruebas unitarias para reglas de negocio (como en la sección 6.3).
- Usa pruebas de integración para validar interacciones entre microservicios.
-
Optimiza con .NET 9:
- Aprovecha minimal APIs para endpoints ligeros.
- Usa mejoras de EF Core 9, como serialización JSON y optimizaciones de consultas.
-
Despliega con Docker:
- Conteneriza microservicios para consistencia entre entornos.
- Usa orquestadores como Kubernetes para escalabilidad.
-
Documenta el dominio:
- Mantén un glosario del lenguaje ubicuo en una wiki.
- Usa herramientas como PlantUML para visualizar contextos y relaciones.
7.3 Lecciones aprendidas de proyectos reales
En KitsuneData, hemos enfrentado desafíos al aplicar DDD:
- Colaboración inicial: La falta de acceso a expertos del dominio puede llevar a modelos imprecisos. Mitiga esto con talleres frecuentes.
- Complejidad de CQRS: Implementar CQRS puede ser costoso al inicio; comienza con un enfoque simple y escala según necesidades.
- Performance en Event Sourcing: La reconstrucción de estados puede ser lenta en agregados con muchos eventos; usa snapshots para optimizar.
- Mantenimiento de microservicios: Define límites claros entre contextos para evitar acoplamiento.
7.4 Integración con microservicios
El caso de estudio muestra cómo los microservicios de Pedidos e Inventarios colaboran mediante eventos:
- Eventos de integración:
PedidoConfirmadoEvent
conecta ambos contextos. - Autonomía: Cada microservicio tiene su propia base de datos (SQL Server para Pedidos, posiblemente NoSQL para Inventarios en escenarios avanzados).
- Escalabilidad: CQRS permite optimizar consultas de stock sin afectar el modelo de escritura.
7.5 Resumen del caso de estudio
Este caso ha integrado:
- DDD táctico: Agregado
Producto
con reglas de negocio. - CQRS: Comandos y consultas separados para gestionar stock.
- Microservicios: Comunicación asíncrona mediante eventos.
- .NET 9: Uso de minimal APIs y EF Core 9.
- PlantUML: Visualización de la arquitectura.
En el próximo capítulo, exploraremos cómo escalar sistemas DDD con patrones de integración avanzados y despliegue en la nube.