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

Relacionamentos um-para-muitos no EF Core — A Explicação Completa

Domine os relacionamentos um-para-muitos no EF Core: propriedades de navegação, chaves estrangeiras, configuração com Fluent API, carregamento eager/lazy/explícito e opções de exclusão em cascata.

#entity-framework#dotnet#database

Um-para-muitos é o relacionamento mais comum em bancos de dados relacionais. Uma Category tem muitos Products. Um Author tem muitos Posts. Entender como o EF Core lida com esse relacionamento — incluindo suas estratégias de carregamento — é essencial para escrever código de acesso a dados correto e com bom desempenho.

Cada estratégia de carregamento e abordagem CRUD deste artigo tem uma contagem medida de instruções SQL, produzida por samples/ef-core-one-to-many — um projeto de console executável que usa exatamente estas entidades. A tabela completa está no final do artigo.

Configuração básica

Duas entidades com um relacionamento um-para-muitos:

// Uma categoria tem muitos produtos
public class Category
{
    public int Id { get; set; }
    public required string Name { get; set; }
 
    // Propriedade de navegação de coleção (o lado "muitos")
    public List<Product> Products { get; set; } = [];
}
 
// Muitos produtos pertencem a uma categoria
public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public decimal Price { get; set; }
 
    // Propriedade de chave estrangeira
    public int CategoryId { get; set; }
 
    // Propriedade de navegação de referência (o lado "um")
    public Category Category { get; set; } = null!;
}

As convenções do EF Core lidam com isso automaticamente:

  • CategoryId é detectado como a chave estrangeira de Category
  • A coleção Products em Category se vincula de volta a Product
  • O relacionamento é configurado sem nenhuma Fluent API

Configuração com Fluent API

Para controle explícito sobre o relacionamento:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        // Configurar o relacionamento de chave estrangeira explicitamente
        entity.HasOne(p => p.Category)           // Product tem uma Category
              .WithMany(c => c.Products)          // Category tem muitos Products
              .HasForeignKey(p => p.CategoryId)   // FK é CategoryId
              .IsRequired()                       // Não pode ser null
              .OnDelete(DeleteBehavior.Restrict); // Não excluir em cascata
    });
}

Configuração em arquivos separados

Para projetos grandes, use 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 no DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}

Opções de chave estrangeira

Relacionamentos obrigatórios vs. opcionais

// Obrigatório (FK não nulável) — o produto DEVE ter uma categoria
public int CategoryId { get; set; }         // Não nulável
public Category Category { get; set; } = null!;
 
// Opcional (FK nulável) — o produto PODE ter uma categoria
public int? CategoryId { get; set; }        // Nulável
public Category? Category { get; set; }

Propriedades shadow

Se você não quiser que a FK fique visível na entidade, o EF Core pode gerenciá-la como uma propriedade shadow:

public class Product
{
    public int Id { get; set; }
    public required string Name { get; set; }
    // Sem propriedade CategoryId — a FK é uma propriedade shadow
 
    public Category Category { get; set; } = null!;
}
 
// Fluent API — EF Core cria a coluna FK no BD mas não na classe
modelBuilder.Entity<Product>()
    .HasOne(p => p.Category)
    .WithMany(c => c.Products)
    .HasForeignKey("CategoryId"); // Nome da propriedade shadow

Estratégias de carregamento

O EF Core tem três estratégias para carregar dados relacionados: eager, explícito e lazy.

Carregamento eager (Include)

Carrega dados relacionados na mesma consulta usando Include():

// Carregar produtos COM sua categoria em uma única consulta SQL (JOIN)
var products = await _db.Products
    .Include(p => p.Category)
    .ToListAsync();
 
// Carregar categoria COM todos os seus produtos
var category = await _db.Categories
    .Include(c => c.Products)
    .FirstOrDefaultAsync(c => c.Id == categoryId);
 
// Includes aninhados (ThenInclude)
var orders = await _db.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
            .ThenInclude(p => p.Category)
    .ToListAsync();
💡

O carregamento eager é o padrão correto para a maioria dos cenários. Evita consultas N+1 e mantém o acesso a dados previsível. Use Include() quando souber que vai precisar dos dados relacionados.

Include filtrado (EF Core 5+)

Carrega apenas um subconjunto da coleção relacionada:

// Carregar categorias com apenas produtos acima de $50
var categories = await _db.Categories
    .Include(c => c.Products.Where(p => p.Price > 50))
    .ToListAsync();
 
// Carregar categorias com produtos ordenados por preço
var categories = await _db.Categories
    .Include(c => c.Products.OrderBy(p => p.Price).Take(5))
    .ToListAsync();

Carregamento explícito

Carrega a propriedade de navegação sob demanda, após a entidade já ter sido carregada:

// Carregar a categoria primeiro
var category = await _db.Categories.FindAsync(categoryId);
 
// Depois, carregar seus produtos explicitamente
await _db.Entry(category!)
         .Collection(c => c.Products)
         .LoadAsync();
 
// Ou carregar com um filtro
await _db.Entry(category!)
         .Collection(c => c.Products)
         .Query()
         .Where(p => p.Price > 100)
         .LoadAsync();
 
// Carregar uma propriedade de navegação de referência explicitamente
var product = await _db.Products.FindAsync(productId);
await _db.Entry(product!).Reference(p => p.Category).LoadAsync();

Carregamento lazy

O carregamento lazy carrega automaticamente as propriedades de navegação quando são acessadas. Requer propriedades de navegação virtuais e o pacote de proxies para lazy loading:

dotnet add package Microsoft.EntityFrameworkCore.Proxies
// Habilitar proxies de lazy loading
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseLazyLoadingProxies();
});
 
// Entidades precisam de propriedades de navegação virtuais
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!
}
// Com lazy loading — Category é carregada automaticamente ao ser acessada
var product = await _db.Products.FindAsync(productId);
var categoryName = product!.Category.Name; // Dispara uma consulta ao BD aqui
⚠️

O carregamento lazy é uma fonte frequente de problemas de consultas N+1. Quando você itera sobre uma coleção e acessa uma propriedade de navegação em cada item, gera uma consulta por item. Evite o lazy loading em aplicações web, a menos que tenha uma razão específica.

Opções de exclusão em cascata

Quando uma categoria é excluída, o que acontece com seus produtos?

entity.HasOne(p => p.Category)
      .WithMany(c => c.Products)
      .HasForeignKey(p => p.CategoryId)
      .OnDelete(DeleteBehavior.Restrict); // Opções abaixo
OpçãoComportamento
CascadeExclui os registros filhos quando o pai é excluído
RestrictLança um erro se o pai tiver filhos (padrão seguro)
SetNullDefine a FK como null quando o pai é excluído (requer FK nulável)
ClientSetNullEF Core carrega e anula as entidades relacionadas em memória (não no nível do BD)
NoActionO banco de dados trata isso (ou lança um erro do BD)
💡

Use Restrict como padrão para relacionamentos obrigatórios. Previne perda acidental de dados e obriga você a tratar explicitamente os registros órfãos na sua lógica de negócio.

Operações CRUD com relacionamentos

Criando com um relacionamento

// Abordagem 1: Usar a chave estrangeira (mais eficiente — sem consulta extra)
var product = new Product
{
    Name = "Widget",
    Price = 9.99m,
    CategoryId = existingCategoryId  // Apenas definir a FK
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
 
// Abordagem 2: Atribuir a propriedade de navegação (EF Core resolve a 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();
 
// Abordagem 3: Adicionar à coleção do pai
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();

Consultando através do relacionamento

// Produtos de uma categoria específica
var products = await _db.Products
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();
 
// Ou via propriedade de navegação (mesmo SQL)
var category = await _db.Categories
    .Include(c => c.Products)
    .FirstAsync(c => c.Id == categoryId);
 
var products = category.Products;
 
// Contar produtos por categoria
var categorySummaries = await _db.Categories
    .Select(c => new
    {
        c.Name,
        ProductCount = c.Products.Count,
        AveragePrice = c.Products.Average(p => (decimal?)p.Price)
    })
    .ToListAsync();

Movendo um produto para outra categoria

public async Task MoveToCategoryAsync(int productId, int newCategoryId)
{
    var product = await _db.Products.FindAsync(productId)
        ?? throw new KeyNotFoundException();
 
    product.CategoryId = newCategoryId; // Apenas atualizar a FK
    await _db.SaveChangesAsync();
}

Removendo um produto de uma categoria

Para relacionamentos obrigatórios, remover da coleção significa excluir a entidade:

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);
        // Com DeleteBehavior.Cascade ou Restrict, isso exclui o produto
        await _db.SaveChangesAsync();
    }
}

Entidades próprias (objetos de valor)

Quando uma entidade é conceitualmente "parte do" pai e não tem identidade própria, use entidades próprias:

public class Order
{
    public int Id { get; set; }
    public required string CustomerEmail { get; set; }
    public Address ShippingAddress { get; set; } = null!;  // Própria
}
 
[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);
 
// Armazena as colunas de ShippingAddress na tabela Orders:
// ShippingAddress_Street, ShippingAddress_City, etc.

Entidades próprias não têm seu próprio DbSet e não podem ser consultadas de forma independente.

Verifique os custos você mesmo

Cada estratégia deste artigo, medida como contagem de instruções SQL contra um banco de dados com 5 categorias e 20 produtos (EF Core 10, SQL Server LocalDB):

EstratégiaInstruções SQL
Include (eager, uma única consulta com JOIN)1
Include filtrado (Price > 50)1
Carregamento explícito (pai, depois coleção)2
Lazy loading (20 produtos, 5 categorias)6
Criar: definir a propriedade FK1
Criar: atribuir a navegação (Find + add)2
Mover para outra categoria (Find + salvar)2

Duas dessas linhas merecem um segundo olhar:

Lazy loading reporta 6, não 21. Carregar 20 produtos custa uma consulta; acessar product.Category em um loop dispara depois uma consulta lazy para o primeiro produto de cada categoria, e o navigation fixup do EF Core anexa essa categoria a todos os outros produtos rastreados que apontam para ela. O custo escala com os pais distintos acessados — cinco categorias aqui, cinco consultas extras. Com um produto por pai, ou com AsNoTracking() (que desativa o fixup), degenera em um N+1 completo. Essa dependência dos dados é exatamente o motivo pelo qual o lazy loading passa nos testes e falha em produção.

Criar via FK é um único INSERT. A abordagem da propriedade de navegação custa duas instruções apenas por causa do Find que buscou o pai — o EF Core não adiciona sobrecarga própria.

Saída de console do sample um-para-muitos: uma tabela de contagem de instruções com 1 instrução para Include eager e Include filtrado, 2 para carregamento explícito, 6 para lazy loading sobre 20 produtos em 5 categorias, 1 para criar via a propriedade FK, e 2 tanto para criar via a navegação quanto para mover um produto entre categorias; abaixo, excluir uma categoria que ainda tem produtos lança DbUpdateException porque a restrição FK aplica DeleteBehavior.Restrict, e uma nota final explica que o lazy loading custa uma consulta por pai realmente acessado.
A tabela acima, direto do console — mesma execução, nada redigitado. Segue a demo do Restrict: a FK rejeita o DELETE e nenhum produto fica órfão.
💡

O programa que produziu esses números é samples/ef-core-one-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 — ele também demonstra o DeleteBehavior.Restrict rejeitando a exclusão de uma categoria que ainda tem produtos.

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