Uno a muchos es la relación más común en las bases de datos relacionales. Una Category tiene muchos Products. Un Author tiene muchos Posts. Entender cómo EF Core gestiona esta relación, incluidas sus estrategias de carga, es esencial para escribir código de acceso a datos correcto y con buen rendimiento.
Cada estrategia de carga y enfoque CRUD de este artículo tiene un conteo medido de sentencias SQL, producido por
samples/ef-core-one-to-many
— un proyecto de consola ejecutable que usa exactamente estas entidades. La tabla completa está al final del artículo.
Configuración básica
Dos entidades con una relación uno a muchos:
// Una categoría tiene muchos productos
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
// Propiedad de navegación de colección (el lado "muchos")
public List<Product> Products { get; set; } = [];
}
// Muchos productos pertenecen a una categoría
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
// Propiedad de clave foránea
public int CategoryId { get; set; }
// Propiedad de navegación de referencia (el lado "uno")
public Category Category { get; set; } = null!;
}Las convenciones de EF Core se encargan de esto automáticamente:
CategoryIdse detecta como la clave foránea deCategory- La colección
ProductsenCategoryse enlaza de vuelta aProduct - La relación se configura sin necesidad de Fluent API
Configuración con Fluent API
Para tener control explícito sobre la relación:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
// Configurar la relación de clave foránea de forma explícita
entity.HasOne(p => p.Category) // Product tiene una Category
.WithMany(c => c.Products) // Category tiene muchos Products
.HasForeignKey(p => p.CategoryId) // FK es CategoryId
.IsRequired() // No puede ser null
.OnDelete(DeleteBehavior.Restrict); // No eliminar en cascada
});
}Configuración en archivos separados
Para proyectos grandes, usa IEntityTypeConfiguration<T>:
// Configuration/ProductConfiguration.cs
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
builder.Property(p => p.Price)
.HasPrecision(18, 2);
builder.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
}
}
// Registrar en DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}Opciones de clave foránea
Relaciones requeridas vs. opcionales
// Requerida (FK no nulable) — el producto DEBE tener una categoría
public int CategoryId { get; set; } // No nulable
public Category Category { get; set; } = null!;
// Opcional (FK nulable) — el producto PUEDE tener una categoría
public int? CategoryId { get; set; } // Nulable
public Category? Category { get; set; }Propiedades shadow
Si no quieres que la FK sea visible en la entidad, EF Core puede gestionarla como una propiedad shadow:
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
// Sin propiedad CategoryId — la FK es una propiedad shadow
public Category Category { get; set; } = null!;
}
// Fluent API — EF Core crea la columna FK en la BD pero no en la clase
modelBuilder.Entity<Product>()
.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey("CategoryId"); // Nombre de la propiedad shadowEstrategias de carga
EF Core dispone de tres estrategias para cargar datos relacionados: eager, explícita y lazy.
Carga eager (Include)
Carga los datos relacionados en la misma consulta usando Include():
// Cargar productos CON su categoría en una sola consulta SQL (JOIN)
var products = await _db.Products
.Include(p => p.Category)
.ToListAsync();
// Cargar categoría CON todos sus productos
var category = await _db.Categories
.Include(c => c.Products)
.FirstOrDefaultAsync(c => c.Id == categoryId);
// Includes anidados (ThenInclude)
var orders = await _db.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ThenInclude(p => p.Category)
.ToListAsync();La carga eager es la opción correcta por defecto para la mayoría de los escenarios. Evita las consultas N+1 y mantiene el acceso a datos predecible. Usa Include() cuando sepas de antemano que necesitarás los datos relacionados.
Include filtrado (EF Core 5+)
Carga solo un subconjunto de la colección relacionada:
// Cargar categorías con solo los productos con precio mayor a $50
var categories = await _db.Categories
.Include(c => c.Products.Where(p => p.Price > 50))
.ToListAsync();
// Cargar categorías con productos ordenados por precio
var categories = await _db.Categories
.Include(c => c.Products.OrderBy(p => p.Price).Take(5))
.ToListAsync();Carga explícita
Carga la propiedad de navegación bajo demanda, después de que la entidad ya ha sido cargada:
// Cargar la categoría primero
var category = await _db.Categories.FindAsync(categoryId);
// Después, cargar sus productos de forma explícita
await _db.Entry(category!)
.Collection(c => c.Products)
.LoadAsync();
// O cargar con un filtro
await _db.Entry(category!)
.Collection(c => c.Products)
.Query()
.Where(p => p.Price > 100)
.LoadAsync();
// Cargar una propiedad de navegación de referencia de forma explícita
var product = await _db.Products.FindAsync(productId);
await _db.Entry(product!).Reference(p => p.Category).LoadAsync();Carga lazy
La carga lazy carga automáticamente las propiedades de navegación cuando se accede a ellas. Requiere propiedades de navegación virtuales y el paquete de proxies para carga lazy:
dotnet add package Microsoft.EntityFrameworkCore.Proxies// Habilitar proxies de carga lazy
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.UseLazyLoadingProxies();
});
// Las entidades necesitan propiedades de navegación virtuales
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
public virtual List<Product> Products { get; set; } = []; // virtual!
}
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public int CategoryId { get; set; }
public virtual Category Category { get; set; } = null!; // virtual!
}// Con carga lazy — Category se carga automáticamente al acceder
var product = await _db.Products.FindAsync(productId);
var categoryName = product!.Category.Name; // Genera una consulta a la BD aquíLa carga lazy es una fuente frecuente de problemas de consultas N+1. Cuando iteras sobre una colección y accedes a una propiedad de navegación en cada elemento, generas una consulta por elemento. Evita la carga lazy en aplicaciones web salvo que tengas una razón específica.
Opciones de eliminación en cascada
Cuando se elimina una categoría, ¿qué ocurre con sus productos?
entity.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict); // Opciones a continuación| Opción | Comportamiento |
|---|---|
Cascade | Elimina los registros hijos cuando se elimina el padre |
Restrict | Lanza un error si el padre tiene hijos (valor seguro por defecto) |
SetNull | Establece la FK a null cuando se elimina el padre (requiere FK nulable) |
ClientSetNull | EF Core carga y pone a null las entidades relacionadas en memoria (no a nivel de BD) |
NoAction | La base de datos lo gestiona (o lanza un error de BD) |
Usa Restrict como valor por defecto para relaciones requeridas. Previene la pérdida accidental de datos y te obliga a manejar explícitamente los registros huérfanos en tu lógica de negocio.
Operaciones CRUD con relaciones
Crear con una relación
// Enfoque 1: Usar la clave foránea (más eficiente — sin consulta extra)
var product = new Product
{
Name = "Widget",
Price = 9.99m,
CategoryId = existingCategoryId // Solo establecer la FK
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
// Enfoque 2: Asignar la propiedad de navegación (EF Core resuelve la FK)
var category = await _db.Categories.FindAsync(categoryId);
var product = new Product { Name = "Widget", Price = 9.99m, Category = category! };
_db.Products.Add(product);
await _db.SaveChangesAsync();
// Enfoque 3: Agregar a la colección del padre
var category = await _db.Categories
.Include(c => c.Products)
.FirstAsync(c => c.Id == categoryId);
category.Products.Add(new Product { Name = "Widget", Price = 9.99m });
await _db.SaveChangesAsync();Consultas a través de la relación
// Productos de una categoría específica
var products = await _db.Products
.Where(p => p.CategoryId == categoryId)
.ToListAsync();
// O mediante la propiedad de navegación (mismo SQL)
var category = await _db.Categories
.Include(c => c.Products)
.FirstAsync(c => c.Id == categoryId);
var products = category.Products;
// Contar productos por categoría
var categorySummaries = await _db.Categories
.Select(c => new
{
c.Name,
ProductCount = c.Products.Count,
AveragePrice = c.Products.Average(p => (decimal?)p.Price)
})
.ToListAsync();Mover un producto a otra categoría
public async Task MoveToCategoryAsync(int productId, int newCategoryId)
{
var product = await _db.Products.FindAsync(productId)
?? throw new KeyNotFoundException();
product.CategoryId = newCategoryId; // Solo actualizar la FK
await _db.SaveChangesAsync();
}Eliminar un producto de una categoría
Para relaciones requeridas, eliminar de la colección significa eliminar la entidad:
public async Task RemoveProductAsync(int categoryId, int productId)
{
var category = await _db.Categories
.Include(c => c.Products)
.FirstAsync(c => c.Id == categoryId);
var product = category.Products.FirstOrDefault(p => p.Id == productId);
if (product is not null)
{
category.Products.Remove(product);
// Con DeleteBehavior.Cascade o Restrict, esto elimina el producto
await _db.SaveChangesAsync();
}
}Entidades propias (objetos de valor)
Cuando una entidad es conceptualmente "parte de" su padre y no tiene identidad propia, usa entidades propias:
public class Order
{
public int Id { get; set; }
public required string CustomerEmail { get; set; }
public Address ShippingAddress { get; set; } = null!; // Propia
}
[Owned]
public class Address
{
public required string Street { get; set; }
public required string City { get; set; }
public required string PostalCode { get; set; }
public required string Country { get; set; }
}// Fluent API
modelBuilder.Entity<Order>()
.OwnsOne(o => o.ShippingAddress);
// Almacena las columnas de ShippingAddress en la tabla Orders:
// ShippingAddress_Street, ShippingAddress_City, etc.Las entidades propias no tienen su propio DbSet y no pueden consultarse de forma independiente.
Comprueba los costes tú mismo
Cada estrategia de este artículo, medida como conteo de sentencias SQL contra una base de datos con 5 categorías y 20 productos (EF Core 10, SQL Server LocalDB):
| Estrategia | Sentencias SQL |
|---|---|
Include (eager, una sola consulta con JOIN) | 1 |
Include filtrado (Price > 50) | 1 |
| Carga explícita (padre, luego colección) | 2 |
| Lazy loading (20 productos, 5 categorías) | 6 |
| Crear: asignar la propiedad FK | 1 |
Crear: asignar la navegación (Find + add) | 2 |
Mover a otra categoría (Find + guardar) | 2 |
Dos de estas filas merecen una segunda mirada:
Lazy loading reporta 6, no 21. Cargar 20 productos cuesta una consulta; tocar
product.Category en un bucle dispara después una consulta lazy por el primer producto
de cada categoría, y el navigation fixup de EF Core adjunta esa categoría a todos los
demás productos rastreados que apuntan a ella. El coste escala con los padres distintos
tocados — cinco categorías aquí, cinco consultas extra. Con un producto por padre, o con
AsNoTracking() (que desactiva el fixup), degenera en un N+1
completo. Esa dependencia de los datos es exactamente la razón por la que el lazy loading
pasa las pruebas y falla en producción.
Crear vía FK es un solo INSERT. El enfoque de propiedad de navegación cuesta dos
sentencias solo por el Find que trajo al padre — EF Core no añade sobrecarga propia.

El programa que produjo estos números es
samples/ef-core-one-to-many.
Ejecuta primero su seed.sql
y luego dotnet run. Los conteos de sentencias no dependen del proveedor ni del hardware,
así que tus números deberían coincidir exactamente — también demuestra cómo
DeleteBehavior.Restrict rechaza eliminar una categoría que todavía tiene productos.