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

Relaciones Muchos-a-Muchos en EF Core con .NET 8

Guía completa sobre relaciones muchos-a-muchos en EF Core: tablas de unión implícitas, entidades de unión explícitas, consultas a través de colecciones, y cómo agregar o eliminar elementos con EF Core 8.

#entity-framework#dotnet#database

Las relaciones muchos-a-muchos modelan escenarios donde los registros de ambos lados pueden relacionarse con múltiples registros del otro lado: las publicaciones tienen muchas etiquetas, las etiquetas aparecen en muchas publicaciones. EF Core 5 introdujo las relaciones muchos-a-muchos implícitas, eliminando la necesidad de una clase de entidad de unión. EF Core 8 perfecciona esto aún más.

Cada consulta y escritura de este artículo tiene un conteo medido de sentencias SQL, producido por samples/ef-core-many-to-many — un proyecto de consola ejecutable que usa exactamente estas entidades y cubre ambas formas, la implícita y la explícita. La tabla completa está en Comprueba los costes tú mismo.

Muchos-a-Muchos Implícito (EF Core 5+)

El enfoque más sencillo — simplemente agrega propiedades de navegación de colección en ambos lados:

public class Post
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public required string Content { get; set; }
    public DateTime PublishedAt { get; set; }
 
    // Muchos-a-muchos: una publicación tiene muchas etiquetas
    public List<Tag> Tags { get; set; } = [];
}
 
public class Tag
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Slug { get; set; }
    public bool IsActive { get; set; } = true;
 
    // Muchos-a-muchos: una etiqueta aparece en muchas publicaciones
    public List<Post> Posts { get; set; } = [];
}

EF Core automáticamente:

  • Crea una tabla de unión PostTag con columnas PostsId y TagsId
  • Gestiona las inserciones/eliminaciones en la tabla de unión al modificar las colecciones
  • No se necesita clase de entidad adicional ni DbSet para la tabla de unión

Opcional: Personalizar el Nombre de la Tabla de Unión

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>()
        .HasMany(p => p.Tags)
        .WithMany(t => t.Posts)
        .UsingEntity(j => j.ToTable("PostTags")); // Nombre de tabla personalizado
}

Entidad de Unión Explícita

Cuando necesitas columnas adicionales en la tabla de unión (por ejemplo, marcas de tiempo, número ordinal), usa una entidad de unión explícita:

// La entidad de unión — tiene sus propias propiedades más allá de las FK
public class StudentCourse
{
    public int StudentId { get; set; }
    public Student Student { get; set; } = null!;
 
    public int CourseId { get; set; }
    public Course Course { get; set; } = null!;
 
    // Datos adicionales
    public DateTime EnrolledAt { get; set; }
    public Grade? FinalGrade { get; set; }
}
 
public class Student
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required string Email { get; set; }
 
    // Se puede navegar directamente a Courses (omitiendo la entidad de unión)
    public List<Course> Courses { get; set; } = [];
 
    // O navegar a través de la entidad de unión (cuando necesitas los datos adicionales)
    public List<StudentCourse> StudentCourses { get; set; } = [];
}
 
public class Course
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public int Credits { get; set; }
 
    public List<Student> Students { get; set; } = [];
    public List<StudentCourse> StudentCourses { get; set; } = [];
}
 
public enum Grade { A, B, C, D, F }
// Fluent API para entidad de unión explícita
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<StudentCourse>(entity =>
    {
        entity.HasKey(sc => new { sc.StudentId, sc.CourseId }); // PK compuesta
 
        entity.HasOne(sc => sc.Student)
              .WithMany(s => s.StudentCourses)
              .HasForeignKey(sc => sc.StudentId);
 
        entity.HasOne(sc => sc.Course)
              .WithMany(c => c.StudentCourses)
              .HasForeignKey(sc => sc.CourseId);
 
        entity.Property(sc => sc.EnrolledAt)
              .HasDefaultValueSql("GETUTCDATE()");
    });
 
    // Configurar navegaciones de salto (acceso directo a Student.Courses)
    modelBuilder.Entity<Student>()
        .HasMany(s => s.Courses)
        .WithMany(c => c.Students)
        .UsingEntity<StudentCourse>();
}

Consultas a Través de la Relación

Include Básico

// Cargar publicaciones con sus etiquetas
var posts = await _db.Posts
    .Include(p => p.Tags)
    .OrderBy(p => p.PublishedAt)
    .ToListAsync();
 
// Cargar etiquetas con sus publicaciones
var tag = await _db.Tags
    .Include(t => t.Posts)
    .FirstOrDefaultAsync(t => t.Slug == "entity-framework");

Include Filtrado (EF Core 5+)

// Cargar publicaciones solo con etiquetas publicadas y no en borrador
var posts = await _db.Posts
    .Include(p => p.Tags.Where(t => t.IsActive))
    .ToListAsync();

Consultas con Condiciones en el Lado Relacionado

// Encontrar todas las publicaciones que tienen la etiqueta "dotnet"
var dotnetPosts = await _db.Posts
    .Where(p => p.Tags.Any(t => t.Slug == "dotnet"))
    .Include(p => p.Tags)
    .ToListAsync();
 
// Encontrar todas las etiquetas usadas en publicaciones del año actual
var recentTags = await _db.Tags
    .Where(t => t.Posts.Any(p => p.PublishedAt.Year == 2025))
    .OrderBy(t => t.Name)
    .ToListAsync();

Consultas a Través de la Entidad de Unión Explícita

// Encontrar todos los cursos en los que un estudiante está inscrito junto con su calificación
var studentCourses = await _db.StudentCourses  // Requiere DbSet<StudentCourse>
    .Where(sc => sc.StudentId == studentId)
    .Include(sc => sc.Course)
    .OrderByDescending(sc => sc.EnrolledAt)
    .ToListAsync();
 
// Estudiantes con calificación A en el curso 5
var topStudents = await _db.StudentCourses
    .Where(sc => sc.CourseId == 5 && sc.FinalGrade == Grade.A)
    .Include(sc => sc.Student)
    .Select(sc => sc.Student)
    .ToListAsync();
 
// O mediante navegación de salto — mismo resultado, sintaxis más limpia
var topStudents = await _db.Courses
    .Where(c => c.Id == 5)
    .SelectMany(c => c.StudentCourses
        .Where(sc => sc.FinalGrade == Grade.A)
        .Select(sc => sc.Student))
    .ToListAsync();

Agregar Elementos a la Colección

Muchos-a-Muchos Implícito

// Agregar una etiqueta a una publicación — EF Core gestiona la entrada en la tabla de unión
public async Task AddTagToPostAsync(int postId, int tagId)
{
    var post = await _db.Posts
        .Include(p => p.Tags)
        .FirstOrDefaultAsync(p => p.Id == postId)
        ?? throw new KeyNotFoundException($"Post {postId} not found");
 
    var tag = await _db.Tags.FindAsync(tagId)
        ?? throw new KeyNotFoundException($"Tag {tagId} not found");
 
    if (!post.Tags.Contains(tag))
    {
        post.Tags.Add(tag);
        await _db.SaveChangesAsync();
    }
}

Sin Cargar la Colección Completa

Evita cargar todas las etiquetas cuando solo quieres agregar una:

// Eficiente — no es necesario cargar la colección Tags
public async Task AddTagToPostAsync(int postId, int tagId)
{
    // Adjuntar entidades stub (no se necesita consulta a la BD). Los miembros required
    // siguen exigiendo un valor al construir; null! es la forma honesta de decir
    // "nunca se lee — aquí solo importa la clave".
    var post = new Post { Id = postId, Title = null!, Content = null! };
    var tag = new Tag { Id = tagId, Name = null!, Slug = null! };
 
    _db.Attach(post);
    _db.Attach(tag);
 
    post.Tags.Add(tag);
    await _db.SaveChangesAsync(); // exactamente un INSERT en la tabla de unión
}
💡

Usa Attach() + agregar a la colección cuando solo quieras insertar una fila en la tabla de unión sin consultar las entidades completas. Esto evita lecturas innecesarias a la base de datos.

Entidad de Unión Explícita — Inscribir un Estudiante

public async Task EnrollStudentAsync(int studentId, int courseId)
{
    // Verificar si ya está inscrito
    var existing = await _db.StudentCourses
        .AnyAsync(sc => sc.StudentId == studentId && sc.CourseId == courseId);
 
    if (existing)
        throw new InvalidOperationException("Student already enrolled in this course");
 
    var enrollment = new StudentCourse
    {
        StudentId = studentId,
        CourseId = courseId,
        EnrolledAt = DateTime.UtcNow
    };
 
    _db.StudentCourses.Add(enrollment);
    await _db.SaveChangesAsync();
}

Eliminar Elementos de la Colección

Muchos-a-Muchos Implícito

// Eliminar una etiqueta de una publicación
public async Task RemoveTagFromPostAsync(int postId, int tagId)
{
    var post = await _db.Posts
        .Include(p => p.Tags)
        .FirstOrDefaultAsync(p => p.Id == postId)
        ?? throw new KeyNotFoundException();
 
    var tag = post.Tags.FirstOrDefault(t => t.Id == tagId);
    if (tag is not null)
    {
        post.Tags.Remove(tag);
        await _db.SaveChangesAsync();
    }
}

Entidad de Unión Explícita — Dar de Baja a un Estudiante

public async Task UnenrollStudentAsync(int studentId, int courseId)
{
    var enrollment = await _db.StudentCourses
        .FirstOrDefaultAsync(sc => sc.StudentId == studentId && sc.CourseId == courseId)
        ?? throw new KeyNotFoundException("Enrollment not found");
 
    _db.StudentCourses.Remove(enrollment);
    await _db.SaveChangesAsync();
}

Operaciones en Lote

// Eliminar todas las etiquetas de una publicación (EF Core 7+ ExecuteDeleteAsync)
await _db.Set<Dictionary<string, object>>("PostTag")
    .Where(pt => (int)pt["PostsId"] == postId)
    .ExecuteDeleteAsync();
 
// Enfoque más directo — limpiar y volver a agregar
var post = await _db.Posts
    .Include(p => p.Tags)
    .FirstAsync(p => p.Id == postId);
 
post.Tags.Clear();
post.Tags.AddRange(newTags);
await _db.SaveChangesAsync();

Conteo y Agregaciones

// Contar etiquetas por publicación
var postTagCounts = await _db.Posts
    .Select(p => new { p.Title, TagCount = p.Tags.Count })
    .OrderByDescending(x => x.TagCount)
    .ToListAsync();
 
// Encontrar las etiquetas más populares (más publicaciones)
var popularTags = await _db.Tags
    .Select(t => new { t.Name, PostCount = t.Posts.Count })
    .OrderByDescending(t => t.PostCount)
    .Take(10)
    .ToListAsync();
 
// Calificación promedio por curso (usando entidad de unión explícita)
var courseAverages = await _db.Courses
    .Select(c => new
    {
        c.Title,
        AverageGrade = c.StudentCourses
            .Where(sc => sc.FinalGrade.HasValue)
            .Average(sc => (double?)sc.FinalGrade)
    })
    .ToListAsync();

Errores Comunes

⚠️

Reemplazar la instancia de la colección — post.Tags = newTagList se rastrea, al contrario de una afirmación muy repetida (y de lo que decía una versión anterior de este artículo): DetectChanges compara el contenido de la colección contra su snapshot, así que en un post cargado con Include(p => p.Tags) la asignación produce exactamente los DELETE e INSERT esperados en la tabla de unión. El sample de abajo lo demuestra. La trampa real es reemplazar una colección que nunca cargaste: EF Core no tiene snapshot, ve cada elemento de la lista nueva como una adición, deja las filas de unión existentes en su sitio — y volver a agregar una de ellas falla con error de clave duplicada. Lo que lleva a la regla de verdad:

⚠️

Incluye siempre la propiedad de navegación antes de modificarla: Include(p => p.Tags). Modificar una colección no cargada sin contexto de seguimiento genera fallos silenciosos o errores de clave duplicada. La única excepción deliberada es el patrón de stubs con Attach() de arriba, donde nada se carga por diseño y la colección empieza vacía — las adiciones son exactamente lo que quieres decir.

Comprueba los costes tú mismo

Todo lo anterior, medido como conteo de sentencias SQL contra una base de datos con 5 posts, 4 etiquetas, 3 estudiantes y 3 cursos (EF Core 10, SQL Server LocalDB). La ejecución empieza leyendo la tabla de unión convencional desde INFORMATION_SCHEMAPostTag (PostsId, TagsId), creada por EF Core sin clase de entidad y sin configuración:

OperaciónSentencias SQL
Include de etiquetas (unión implícita)1
Include filtrado (solo etiquetas activas)1
Where(p => p.Tags.Any(...)) + Include1
Entidad de unión con payload (notas + Include)1
Agregación: nota media por curso1
Agregar etiqueta: Include + Find + guardar3
Agregar etiqueta: stubs con Attach(), sin cargar nada1
Quitar etiqueta (Include + guardar)2
En lote: ExecuteDelete sobre las filas de unión1
Inscribir: comprobación de existencia + insertar2

El patrón que conviene interiorizar: las lecturas cuestan una sentencia en ambas formas — la tabla de unión desaparece dentro del JOIN tenga o no clase de entidad — mientras que el coste de las escrituras lo dominan las lecturas previas. El insert con stubs de Attach() es una sentencia; la versión con colección cargada del mismo insert son tres, dos de las cuales traen datos que la escritura nunca necesitó.

La ejecución termina con el experimento de reemplazo de colección del aviso de arriba: el post 2 empieza con tres etiquetas, se guarda post.Tags = [efTag], y la tabla de unión queda después con exactamente una fila — tres DELETE y un INSERT, todo rastreado.

Salida de consola del sample muchos-a-muchos: primero confirma que la tabla de unión convencional PostTag existe con columnas PostsId y TagsId; después una tabla de conteo muestra 1 sentencia para el Include de etiquetas, el Include filtrado de etiquetas activas, el Where con Tags.Any más Include, la consulta de la entidad de unión con payload de notas y la media de notas por curso; 3 sentencias para agregar una etiqueta con la colección cargada frente a 1 con stubs de Attach(); 2 para quitar una etiqueta; 1 para el ExecuteDelete en lote de filas de unión; y 2 para inscribir con comprobación de existencia. Debajo, el experimento de reemplazo de colección muestra las filas de unión del post 2 reducidas a solo entity-framework tras asignar una lista nueva, y una nota final indica que ambas formas leen con una única consulta JOIN.
La tabla de arriba, directa de la consola — misma ejecución, nada retecleado. Observa la primera línea: la tabla PostTag (PostsId, TagsId) leída desde INFORMATION_SCHEMA, y al final el experimento de reemplazo dejando exactamente una fila de unión.
💡

El programa que produjo estos números es samples/ef-core-many-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.

Patrones de Relación Relacionados

Muchos-a-muchos es la más intrincada de las dos formas de relación habituales; si aún estás diseñando el esquema, las relaciones uno-a-muchos cubren el caso más simple y las convenciones que EF Core aplica por defecto.

Ten presente que las tablas de unión facilitan disparar el problema de consultas N+1: cargar una lista de posts y luego tocar post.Tags en un bucle emite una consulta por post, e Include con una segunda colección es justamente el caso de producto cartesiano para el que existe AsSplitQuery().

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