//JorgenHoc
← Todos los artículos
EF CorePor Jorge CalderónActualizado 11 min read

Relaciones uno a muchos en EF Core — La Explicación Completa

Domina las relaciones uno a muchos en EF Core: propiedades de navegación, claves foráneas, configuración con Fluent API, carga eager/lazy/explícita y opciones de eliminación en cascada.

#entity-framework#dotnet#database

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:

  • CategoryId se detecta como la clave foránea de Category
  • La colección Products en Category se enlaza de vuelta a Product
  • 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 shadow

Estrategias 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ónComportamiento
CascadeElimina los registros hijos cuando se elimina el padre
RestrictLanza un error si el padre tiene hijos (valor seguro por defecto)
SetNullEstablece la FK a null cuando se elimina el padre (requiere FK nulable)
ClientSetNullEF Core carga y pone a null las entidades relacionadas en memoria (no a nivel de BD)
NoActionLa 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):

EstrategiaSentencias 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 FK1
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.

Salida de consola del sample uno a muchos: una tabla de conteo de sentencias con 1 sentencia para Include eager e Include filtrado, 2 para carga explícita, 6 para lazy loading sobre 20 productos en 5 categorías, 1 para crear vía la propiedad FK, y 2 tanto para crear vía la navegación como para mover un producto entre categorías; debajo, eliminar una categoría que todavía tiene productos lanza DbUpdateException porque la restricción FK aplica DeleteBehavior.Restrict, y una nota final explica que el lazy loading cuesta una consulta por cada padre realmente tocado.
La tabla de arriba, directa de la consola — misma ejecución, nada retecleado. Sigue la demo de Restrict: la FK rechaza el DELETE y ningún producto queda huérfano.
💡

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.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados