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

DDD Táctico: Building Blocks del Dominio

En los capítulos anteriores, exploramos los fundamentos de Domain-Driven Design (DDD), su enfoque estratégico y los bloques de construcción tácticos, como entidades y agregados. Ahora, nos enfocamos en cómo implementar estos conceptos en .NET 9, con énfasis en la persistencia de datos. Como líder de KitsuneData Integral Solutions, he usado estas técnicas para construir sistemas robustos que alinean el dominio con bases de datos modernas, y en este capítulo te guiaré para lograrlo. Cubriremos los repositorios, el mapeo de agregados con Entity Framework Core 9, las mejoras de .NET 9, la configuración óptima para DDD, y un ejemplo práctico que persiste el agregado Pedido del Capítulo 3.

4.1 Repositorios: Abstrayendo la persistencia

Un repositorio en DDD es una capa que abstrae el acceso a datos, permitiendo que el modelo de dominio permanezca independiente de la infraestructura. Actúa como una colección en memoria, ofreciendo métodos para agregar, actualizar, eliminar y recuperar agregados.

Principios de los repositorios

  • Centrarse en la raíz del agregado: Los repositorios operan solo con la raíz del agregado, garantizando la consistencia.
  • Encapsular la persistencia: La lógica de base de datos (e.g., consultas SQL) se aísla del dominio.
  • Interfaz en el dominio: La interfaz del repositorio pertenece al dominio, mientras que la implementación está en la capa de infraestructura.

Ejemplo en C#:

public interface IPedidoRepository
{
    Task<Pedido> ObtenerPorIdAsync(Guid id);
    Task AgregarAsync(Pedido pedido);
    Task ActualizarAsync(Pedido pedido);
}

En la capa de infraestructura:

public class PedidoRepository : IPedidoRepository
{
    private readonly AppDbContext _context;

    public PedidoRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Pedido> ObtenerPorIdAsync(Guid id)
    {
        return await _context.Pedidos
            .Include(p => p.Lineas)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task AgregarAsync(Pedido pedido)
    {
        await _context.Pedidos.AddAsync(pedido);
        await _context.SaveChangesAsync();
    }

    public async Task ActualizarAsync(Pedido pedido)
    {
        _context.Pedidos.Update(pedido);
        await _context.SaveChangesAsync();
    }
}

Este repositorio abstrae la persistencia del agregado Pedido, manteniendo el dominio limpio.

4.2 Entity Framework Core 9: Mapeo de agregados

Entity Framework Core 9 (EF Core 9) es el ORM de elección para .NET 9, ideal para mapear agregados a bases de datos relacionales como SQL Server. En DDD, el mapeo debe respetar las reglas de los agregados, asegurando que solo la raíz sea accesible.

Estrategias de mapeo
  • Propiedades privadas: Usa constructores privados y propiedades con setters privados para forzar la creación y modificación a través de métodos del dominio.
  • Configuración explícita: Usa la API fluida de EF Core para mapear entidades y objetos de valor.
  • Inclusión de colecciones: Carga colecciones relacionadas (e.g., Lineas en Pedido) solo cuando sea necesario.

Ejemplo de configuración:

public class AppDbContext : DbContext
{
    public DbSet<Pedido> Pedidos { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Pedido>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.ClienteId).IsRequired();
            entity.Property(p => p.Estado).IsRequired();
            entity.Property(p => p.Total).HasPrecision(18, 2);
            entity.OwnsMany(p => p.Lineas, linea =>
            {
                linea.WithOwner().HasForeignKey("PedidoId");
                linea.Property(l => l.ProductoId).IsRequired();
                linea.Property(l => l.Cantidad).IsRequired();
                linea.Property(l => l.Precio).HasPrecision(18, 2);
            });
            entity.OwnsOne(p => p.DireccionEnvio, dir =>
            {
                dir.Property(d => d.Calle).IsRequired();
                dir.Property(d => d.Ciudad).IsRequired();
                dir.Property(d => d.CodigoPostal).IsRequired();
            });
        });
    }
}

Este código configura el agregado Pedido, mapeando Lineas como una colección propia y DireccionEnvio como un objeto de valor.

4.3 Mejoras de .NET 9

.NET 9 introduce mejoras que benefician la persistencia en DDD:

  • Optimizaciones en colecciones: Las colecciones primitivas (List<T>, Dictionary<TKey, TValue>) tienen mejor rendimiento, lo que reduce la sobrecarga al manejar listas como Lineas en Pedido.
  • Serialización JSON mejorada: EF Core 9 soporta serialización JSON nativa, útil para almacenar datos complejos (e.g., objetos de valor) en una sola columna.
  • Rendimiento en consultas: Mejoras en el motor de consultas de EF Core 9 permiten ejecutar consultas más eficientes para agregados.

Ejemplo de serialización JSON:

public class Pedido
{
    // ... otros miembros
    public Direccion DireccionEnvio { get; private set; } // Objeto de valor
}

// Configuración para serializar Direccion como JSON
modelBuilder.Entity<Pedido>()
    .Property(p => p.DireccionEnvio)
    .HasColumnType("jsonb") // Para PostgreSQL; usar 'json' en SQL Server
    .HasConversion(
        v => JsonSerializer.Serialize(v, null),
        v => JsonSerializer.Deserialize<Direccion>(v, null));

En KitsuneData, hemos usado estas mejoras para optimizar la persistencia de agregados en sistemas de gestión empresarial.

4.4 Configuración óptima de EF Core para DDD

Para alinear EF Core 9 con DDD, sigue estas prácticas:

  • Usa constructores privados: Protege la creación de entidades (e.g., private Pedido() { } para EF Core).
  • Mapea objetos de valor: Usa OwnsOne o OwnsMany para objetos de valor como Direccion o Lineas.
  • Evita cargas perezosas: Usa Include explícitamente para cargar datos relacionados y evitar consultas innecesarias.
  • Configura índices: Mejora el rendimiento con índices en columnas frecuentes (e.g., ClienteId).
  • Maneja concurrencia: Usa tokens de concurrencia para proteger agregados en entornos multiusuario.

Ejemplo de concurrencia:

public class Pedido
{
    public Guid Id { get; private set; }
    public Guid ClienteId { get; private set; }
    public byte[] RowVersion { get; private set; } // Para concurrencia
    // ... otros miembros
}

modelBuilder.Entity<Pedido>()
    .Property(p => p.RowVersion)
    .IsRowVersion();

4.5 Ejemplo

Apliquemos los conceptos al agregado Pedido del Capítulo 3, persistiendo sus datos en una base de datos SQL Server usando EF Core 9.

Escenario
  • Contexto: Gestión de pedidos en un sistema de e-commerce.
  • Agregado: Pedido (entidad raíz), con Lineas (colección de objetos de valor) y DireccionEnvio (objeto de valor).
  • Requisitos:
    • Persistir el pedido y sus líneas.
    • Validar reglas de negocio al confirmar el pedido.
    • Publicar un evento PedidoConfirmadoEvent tras la confirmación.
Código
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; }
    public byte[] RowVersion { get; private set; }

    private Pedido() { } // Para EF Core

    public Pedido(Guid clienteId, Direccion direccionEnvio)
    {
        Id = Guid.NewGuid();
        ClienteId = clienteId;
        DireccionEnvio = direccionEnvio?.Validar() ?? throw new ArgumentNullException(nameof(direccionEnvio));
        Estado = "Creado";
    }

    public void AgregarLinea(Guid productoId, int cantidad, decimal precio)
    {
        var linea = new LineaPedido(productoId, cantidad, precio);
        Lineas.Add(linea);
        Total += linea.Subtotal;
    }

    public void Confirmar(IDescuentoService descuentoService)
    {
        if (Lineas.Count == 0)
            throw new InvalidOperationException("El pedido no tiene líneas.");
        Total = descuentoService.CalcularDescuento(ClienteId, Total);
        Estado = "Confirmado";
    }
}

public record LineaPedido(Guid ProductoId, int Cantidad, decimal Precio)
{
    public decimal Subtotal => Cantidad * Precio;
}

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 PedidoConfirmadoEvent(Guid PedidoId, Guid ClienteId, decimal Total, DateTime FechaConfirmacion);

public interface IPedidoRepository
{
    Task<Pedido> ObtenerPorIdAsync(Guid id);
    Task AgregarAsync(Pedido pedido);
    Task ActualizarAsync(Pedido pedido);
}

public class PedidoRepository : IPedidoRepository
{
    private readonly AppDbContext _context;

    public PedidoRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Pedido> ObtenerPorIdAsync(Guid id)
    {
        return await _context.Pedidos
            .Include(p => p.Lineas)
            .Include(p => p.DireccionEnvio)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task AgregarAsync(Pedido pedido)
    {
        await _context.Pedidos.AddAsync(pedido);
        await _context.SaveChangesAsync();
    }

    public async Task ActualizarAsync(Pedido pedido)
    {
        _context.Pedidos.Update(pedido);
        await _context.SaveChangesAsync();
    }
}

public class AppDbContext : DbContext
{
    public DbSet<Pedido> Pedidos { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Pedido>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.ClienteId).IsRequired();
            entity.Property(p => p.Estado).IsRequired();
            entity.Property(p => p.Total).HasPrecision(18, 2);
            entity.Property(p => p.RowVersion).IsRowVersion();
            entity.OwnsMany(p => p.Lineas, linea =>
            {
                linea.WithOwner().HasForeignKey("PedidoId");
                linea.Property(l => l.ProductoId).IsRequired();
                linea.Property(l => l.Cantidad).IsRequired();
                linea.Property(l => l.Precio).HasPrecision(18, 2);
            });
            entity.OwnsOne(p => p.DireccionEnvio, dir =>
            {
                dir.Property(d => d.Calle).IsRequired();
                dir.Property(d => d.Ciudad).IsRequired();
                dir.Property(d => d.CodigoPostal).IsRequired();
            });
        });
    }
}

public class PedidoService
{
    private readonly IPedidoRepository _repository;
    private readonly IDescuentoService _descuentoService;
    private readonly IEventoPublisher _publisher;

    public PedidoService(IPedidoRepository repository, IDescuentoService descuentoService, IEventoPublisher publisher)
    {
        _repository = repository;
        _descuentoService = descuentoService;
        _publisher = publisher;
    }

    public async Task ConfirmarPedidoAsync(Guid pedidoId)
    {
        var pedido = await _repository.ObtenerPorIdAsync(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));
    }
}
Explicación del ejemplo
  • Agregado: Pedido es la raíz, con Lineas y DireccionEnvio como objetos de valor.
  • Repositorio: PedidoRepository abstrae la persistencia con EF Core 9.
  • Configuración: AppDbContext mapea el agregado, incluyendo colecciones (Lineas) y objetos de valor (DireccionEnvio).
  • Servicio: PedidoService coordina la confirmación del pedido, aplicando descuentos y publicando eventos.
  • Concurrencia: RowVersion protege contra actualizaciones simultáneas.

Este ejemplo refleja cómo en KitsuneData persistimos agregados para garantizar consistencia y alineación con el negocio. En el Capítulo 5, extenderemos este código a microservicios. Este capítulo ha mostrado cómo integrar DDD con .NET 9 para persistir agregados. En el próximo capítulo, exploraremos cómo aplicar estos conceptos en arquitecturas de microservicios.