//JorgenHoc
← Todos os artigos
EF CorePor Jorge CalderónAtualizado 11 min read

Relacionamentos Muitos-para-Muitos no EF Core com .NET 8

Guia completo sobre relacionamentos muitos-para-muitos no EF Core: tabelas de junção implícitas, entidades de junção explícitas, consultas por meio de coleções, e como adicionar ou remover itens com o EF Core 8.

#entity-framework#dotnet#database

Os relacionamentos muitos-para-muitos modelam cenários em que registros de ambos os lados podem se relacionar com múltiplos registros do outro lado: publicações têm muitas tags, tags aparecem em muitas publicações. O EF Core 5 introduziu o muitos-para-muitos implícito, eliminando a necessidade de uma classe de entidade de junção. O EF Core 8 aprimora isso ainda mais.

Cada consulta e escrita deste artigo tem uma contagem medida de instruções SQL, produzida por samples/ef-core-many-to-many — um projeto de console executável que usa exatamente estas entidades e cobre as duas formas, a implícita e a explícita. A tabela completa está em Verifique os custos você mesmo.

Muitos-para-Muitos Implícito (EF Core 5+)

A abordagem mais simples — basta adicionar propriedades de navegação de coleção em ambos os 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; }
 
    // Muitos-para-muitos: uma publicação tem muitas tags
    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;
 
    // Muitos-para-muitos: uma tag aparece em muitas publicações
    public List<Post> Posts { get; set; } = [];
}

O EF Core automaticamente:

  • Cria uma tabela de junção PostTag com colunas PostsId e TagsId
  • Gerencia inserções/exclusões na tabela de junção ao modificar as coleções
  • Nenhuma classe de entidade extra ou DbSet é necessário para a tabela de junção

Opcional: Personalizar o Nome da Tabela de Junção

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

Entidade de Junção Explícita

Quando você precisa de colunas adicionais na tabela de junção (por exemplo, timestamps, número ordinal), use uma entidade de junção explícita:

// A entidade de junção — tem suas próprias propriedades além das FKs
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!;
 
    // Dados adicionais
    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; }
 
    // Pode navegar diretamente para Courses (ignorando a entidade de junção)
    public List<Course> Courses { get; set; } = [];
 
    // Ou navegar pela entidade de junção (quando você precisa dos dados adicionais)
    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 entidade de junção explícita
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<StudentCourse>(entity =>
    {
        entity.HasKey(sc => new { sc.StudentId, sc.CourseId }); // PK composta
 
        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 navegações de salto (acesso direto a Student.Courses)
    modelBuilder.Entity<Student>()
        .HasMany(s => s.Courses)
        .WithMany(c => c.Students)
        .UsingEntity<StudentCourse>();
}

Consultando Através do Relacionamento

Include Básico

// Carregar publicações com suas tags
var posts = await _db.Posts
    .Include(p => p.Tags)
    .OrderBy(p => p.PublishedAt)
    .ToListAsync();
 
// Carregar tags com suas publicações
var tag = await _db.Tags
    .Include(t => t.Posts)
    .FirstOrDefaultAsync(t => t.Slug == "entity-framework");

Include Filtrado (EF Core 5+)

// Carregar publicações apenas com tags publicadas e não em rascunho
var posts = await _db.Posts
    .Include(p => p.Tags.Where(t => t.IsActive))
    .ToListAsync();

Consultando com Condições no Lado Relacionado

// Encontrar todas as publicações que têm a tag "dotnet"
var dotnetPosts = await _db.Posts
    .Where(p => p.Tags.Any(t => t.Slug == "dotnet"))
    .Include(p => p.Tags)
    .ToListAsync();
 
// Encontrar todas as tags usadas em publicações deste ano
var recentTags = await _db.Tags
    .Where(t => t.Posts.Any(p => p.PublishedAt.Year == 2025))
    .OrderBy(t => t.Name)
    .ToListAsync();

Consultando Pela Entidade de Junção Explícita

// Encontrar todos os cursos em que um estudante está matriculado com sua nota
var studentCourses = await _db.StudentCourses  // Requer DbSet<StudentCourse>
    .Where(sc => sc.StudentId == studentId)
    .Include(sc => sc.Course)
    .OrderByDescending(sc => sc.EnrolledAt)
    .ToListAsync();
 
// Estudantes com nota A no 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();
 
// Ou via navegação de salto — mesmo resultado, sintaxe mais limpa
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();

Adicionando Itens à Coleção

Muitos-para-Muitos Implícito

// Adicionar uma tag a uma publicação — EF Core gerencia a entrada na tabela de junção
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();
    }
}

Sem Carregar a Coleção Completa

Evite carregar todas as tags quando quiser apenas adicionar uma:

// Eficiente — não é necessário carregar a coleção Tags
public async Task AddTagToPostAsync(int postId, int tagId)
{
    // Anexar entidades stub (sem consulta ao BD necessária). Os membros required
    // ainda exigem um valor na construção; null! é a forma honesta de dizer
    // "nunca é lido — aqui só a chave importa".
    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(); // exatamente um INSERT na tabela de junção
}
💡

Use Attach() + adicionar à coleção quando quiser apenas inserir uma linha na tabela de junção sem consultar as entidades completas. Isso evita leituras desnecessárias no banco de dados.

Entidade de Junção Explícita — Matricular um Estudante

public async Task EnrollStudentAsync(int studentId, int courseId)
{
    // Verificar se já está matriculado
    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();
}

Removendo Itens da Coleção

Muitos-para-Muitos Implícito

// Remover uma tag de uma publicação
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();
    }
}

Entidade de Junção Explícita — Desmatricular um Estudante

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();
}

Operações em Lote

// Remover todas as tags de uma publicação (EF Core 7+ ExecuteDeleteAsync)
await _db.Set<Dictionary<string, object>>("PostTag")
    .Where(pt => (int)pt["PostsId"] == postId)
    .ExecuteDeleteAsync();
 
// Abordagem mais direta — limpar e adicionar novamente
var post = await _db.Posts
    .Include(p => p.Tags)
    .FirstAsync(p => p.Id == postId);
 
post.Tags.Clear();
post.Tags.AddRange(newTags);
await _db.SaveChangesAsync();

Contagem e Agregações

// Contar tags por publicação
var postTagCounts = await _db.Posts
    .Select(p => new { p.Title, TagCount = p.Tags.Count })
    .OrderByDescending(x => x.TagCount)
    .ToListAsync();
 
// Encontrar as tags mais populares (mais publicações)
var popularTags = await _db.Tags
    .Select(t => new { t.Name, PostCount = t.Posts.Count })
    .OrderByDescending(t => t.PostCount)
    .Take(10)
    .ToListAsync();
 
// Nota média por curso (usando entidade de junção 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();

Erros Comuns

⚠️

Substituir a instância da coleção — post.Tags = newTagListé rastreado, ao contrário de uma afirmação muito repetida (e do que dizia uma versão anterior deste artigo): o DetectChanges compara o conteúdo da coleção com seu snapshot, então em um post carregado com Include(p => p.Tags) a atribuição produz exatamente os DELETE e INSERT esperados na tabela de junção. O sample abaixo prova isso. A armadilha real é substituir uma coleção que você nunca carregou: o EF Core não tem snapshot, vê cada item da lista nova como uma adição, deixa as linhas de junção existentes no lugar — e readicionar uma delas falha com erro de chave duplicada. O que leva à regra de verdade:

⚠️

Sempre inclua a propriedade de navegação antes de modificá-la: Include(p => p.Tags). Modificar uma coleção não carregada sem contexto de rastreamento resulta em falhas silenciosas ou erros de chave duplicada. A única exceção deliberada é o padrão de stubs com Attach() acima, em que nada é carregado por design e a coleção começa vazia — adições são exatamente o que você quer dizer.

Verifique os custos você mesmo

Tudo o que está acima, medido como contagem de instruções SQL contra um banco de dados com 5 posts, 4 tags, 3 estudantes e 3 cursos (EF Core 10, SQL Server LocalDB). A execução começa lendo a tabela de junção convencional do INFORMATION_SCHEMAPostTag (PostsId, TagsId), criada pelo EF Core sem classe de entidade e sem configuração:

OperaçãoInstruções SQL
Include de tags (junção implícita)1
Include filtrado (só tags ativas)1
Where(p => p.Tags.Any(...)) + Include1
Entidade de junção com payload (notas + Include)1
Agregação: nota média por curso1
Adicionar tag: Include + Find + salvar3
Adicionar tag: stubs com Attach(), sem carregar nada1
Remover tag (Include + salvar)2
Em lote: ExecuteDelete nas linhas de junção1
Matricular: verificação de existência + inserir2

O padrão que vale internalizar: leituras custam uma instrução nas duas formas — a tabela de junção desaparece dentro do JOIN tendo ou não classe de entidade — enquanto o custo das escritas é dominado pelas leituras feitas antes. O insert com stubs de Attach() é uma instrução; a versão com coleção carregada do mesmo insert são três, duas das quais buscam dados que a escrita nunca precisou.

A execução termina com o experimento de substituição de coleção do aviso acima: o post 2 começa com três tags, post.Tags = [efTag] é salvo, e a tabela de junção fica depois com exatamente uma linha — três DELETE e um INSERT, tudo rastreado.

Saída de console do sample muitos-para-muitos: primeiro confirma que a tabela de junção convencional PostTag existe com as colunas PostsId e TagsId; depois uma tabela de contagem mostra 1 instrução para o Include de tags, o Include filtrado de tags ativas, o Where com Tags.Any mais Include, a consulta da entidade de junção com payload de notas e a média de notas por curso; 3 instruções para adicionar uma tag com a coleção carregada contra 1 com stubs de Attach(); 2 para remover uma tag; 1 para o ExecuteDelete em lote das linhas de junção; e 2 para matricular com verificação de existência. Abaixo, o experimento de substituição de coleção mostra as linhas de junção do post 2 reduzidas a apenas entity-framework após atribuir uma lista nova, e uma nota final indica que as duas formas leem com uma única consulta JOIN.
A tabela acima, direto do console — mesma execução, nada redigitado. Observe a primeira linha: a tabela PostTag (PostsId, TagsId) lida do INFORMATION_SCHEMA, e no final o experimento de substituição deixando exatamente uma linha de junção.
💡

O programa que produziu esses números é samples/ef-core-many-to-many. Execute primeiro o seed.sql e depois dotnet run. Contagens de instruções não dependem de provedor nem de hardware, então seus números devem bater exatamente.

Padrões de Relacionamento Relacionados

Muitos-para-muitos é a mais intrincada das duas formas comuns de relacionamento; se você ainda está desenhando o esquema, os relacionamentos um-para-muitos cobrem o caso mais simples e as convenções que o EF Core aplica por padrão.

Vale lembrar que tabelas de junção facilitam disparar o problema de consultas N+1: carregar uma lista de posts e depois tocar post.Tags em um loop emite uma consulta por post, e Include com uma segunda coleção é exatamente o caso de produto cartesiano para o qual AsSplitQuery() existe.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados