Domain-Driven Design Aplicado a .NET 9
[ Domain-Driven Design Aplicado a .NET 9 ]

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 publica StockActualizadoEvent.
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
Casos de Estudio y Mejores Prácticas

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:

  1. 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.
  2. 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 incluye Stock y Nombre, evitando referencias a otros agregados.
  3. 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.
  4. 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.
  5. 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.
  6. Optimiza con .NET 9:

    • Aprovecha minimal APIs para endpoints ligeros.
    • Usa mejoras de EF Core 9, como serialización JSON y optimizaciones de consultas.
  7. Despliega con Docker:

    • Conteneriza microservicios para consistencia entre entornos.
    • Usa orquestadores como Kubernetes para escalabilidad.
  8. 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.