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
enPedido
) 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 comoLineas
enPedido
. - 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
oOwnsMany
para objetos de valor comoDireccion
oLineas
. - 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), conLineas
(colección de objetos de valor) yDireccionEnvio
(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, conLineas
yDireccionEnvio
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.