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 deCategory- A coleção
ProductsemCategoryse vincula de volta aProduct - 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 shadowEstraté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 aquiO 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ção | Comportamento |
|---|---|
Cascade | Exclui os registros filhos quando o pai é excluído |
Restrict | Lança um erro se o pai tiver filhos (padrão seguro) |
SetNull | Define a FK como null quando o pai é excluído (requer FK nulável) |
ClientSetNull | EF Core carrega e anula as entidades relacionadas em memória (não no nível do BD) |
NoAction | O 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égia | Instruçõ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 FK | 1 |
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.

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.