Cheatsheet Entity Framework
ORM para .NET
Entity Framework
Configuração
Instalar pacotes NuGet
dotnet add package Microsoft.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.EntityFrameworkCore.Design
Os pacotes EntityFrameworkCore (core), SqlServer (provider) e Tools/Design (CLI e migrations) são essenciais. Instala via dotnet add package ou NuGet Manager.
OnModelCreating
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Cliente>(e =>
{
e.HasKey(c => c.Id);
e.Property(c => c.Nome)
.HasMaxLength(100)
.IsRequired();
});
}O OnModelCreating configura entidades via Fluent API. É chamado uma vez na inicialização. Tem prioridade sobre Data Annotations.
Retry on failure
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null
);
});O EnableRetryOnFailure() retenta automaticamente em falhas transitórias de rede. Essencial para cloud (Azure SQL). Configura tentativas e delay.
DbContext básico
public class AppDbContext : DbContext
{
public DbSet<Cliente> Clientes { get; set; }
public DbSet<Produto> Produtos { get; set; }
protected override void OnConfiguring(
DbContextOptionsBuilder options)
{
options.UseSqlServer(connectionString);
}
}O DbContext é a classe principal de acesso a dados. Cada DbSet<T> representa uma tabela. O OnConfiguring define o provider e a connection string.
Múltiplos providers
// SQL Server
options.UseSqlServer(conn);
// PostgreSQL
options.UseNpgsql(conn);
// SQLite
options.UseSqlite("Data Source=app.db");
// InMemory (testes)
options.UseInMemoryDatabase("TestDb");O EF Core suporta múltiplos providers: SqlServer, Npgsql (PostgreSQL), Sqlite e InMemory (testes). Cada um tem o seu pacote NuGet.
Command timeout
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.CommandTimeout(60); // segundos
});
// Ou por consulta:
context.Database.SetCommandTimeout(120);O CommandTimeout define o tempo máximo (segundos) para comandos SQL. O padrão é 30s. Aumenta para queries pesadas ou relatórios.
Dependency Injection
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("Default")
)
);
// Injetar no controller:
public class ClientesController : ControllerBase
{
private readonly AppDbContext _context;
public ClientesController(AppDbContext context)
=> _context = context;
}O AddDbContext regista o contexto no container DI com scope por request. Injeta via construtor nos controllers e serviços.
DbContext Pooling
builder.Services.AddDbContextPool<AppDbContext>(
options => options.UseSqlServer(conn),
poolSize: 128
);O AddDbContextPool reutiliza instâncias de DbContext em vez de criar novas. Melhora performance em cenários de alto débito. O pool é thread-safe.
ApplyConfigurationsFromAssembly
// Classe de configuração separada:
public class ClienteConfig
: IEntityTypeConfiguration<Cliente>
{
public void Configure(EntityTypeBuilder<Cliente> e)
{
e.HasKey(c => c.Id);
e.Property(c => c.Nome).HasMaxLength(100);
}
}
// No OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly);O IEntityTypeConfiguration<T> separa a configuração em classes próprias. O ApplyConfigurationsFromAssembly() regista todas automaticamente.
Connection string
// appsettings.json
"ConnectionStrings": {
"Default": "Server=localhost;Database=MinhaDb;Trusted_Connection=True;TrustServerCertificate=True"
}
// Acesso:
var conn = builder.Configuration
.GetConnectionString("Default");A connection string fica no appsettings.json. O GetConnectionString() lê pelo nome. Para SQL Server usa Trusted_Connection ou user/password.
Logging e Debug
options.UseSqlServer(conn)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();O LogTo() mostra o SQL gerado na consola. O EnableSensitiveDataLogging() inclui valores dos parâmetros. Usa apenas em desenvolvimento.
Segundo DbContext (read-only)
builder.Services.AddDbContext<ReadOnlyContext>(
options => options.UseSqlServer(readReplicaConn),
ServiceLifetime.Transient
);Podes ter múltiplos DbContext para separar leitura/escrita ou ligar a bases diferentes. O ServiceLifetime controla o tempo de vida.
Modelos e Mapeamento
Entidade simples (POCO)
public class Cliente
{
public int Id { get; set; }
public string Nome { get; set; }
public string Email { get; set; }
public DateTime CriadoEm { get; set; }
}Uma entidade é uma classe POCO simples. O EF mapeia automaticamente por convenção: Id é a chave, propriedades viram colunas.
Enum como string
public enum Estado { Ativo, Inativo, Suspenso }
// Fluent API:
e.Property(p => p.Estado)
.HasConversion<string>()
.HasMaxLength(20);
// Ou com Data Annotation:
[EnumDataType(typeof(Estado))]
public string Estado { get; set; }O HasConversion<string>() guarda o enum como texto legível na BD em vez de número. Facilita consultas diretas e debugging.
Default values
e.Property(p => p.CriadoEm)
.HasDefaultValueSql("GETUTCDATE()");
e.Property(p => p.Estado)
.HasDefaultValue("ativo");
e.Property(p => p.Ordem)
.HasDefaultValue(0);O HasDefaultValueSql() define valores SQL (ex.: GETUTCDATE()). O HasDefaultValue() usa valores literais. Aplicados na migration.
Table-per-Type (TPT)
modelBuilder.Entity<Cao>().ToTable("Caes");
modelBuilder.Entity<Gato>().ToTable("Gatos");O TPT cria uma tabela por tipo na hierarquia, ligadas por FK. Normaliza os dados mas as queries são mais complexas (JOINs). Usa ToTable() em cada subtipo.
Data Annotations
public class Produto
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Nome { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Preco { get; set; }
[NotMapped]
public string NomeCompleto => $"{Nome} ({Preco})";
}Os Data Annotations configuram via atributos: [Key], [Required], [MaxLength], [Column], [NotMapped]. Mais simples que Fluent API.
Value Conversions
e.Property(p => p.Tags)
.HasConversion(
v => string.Join(",", v), // ao guardar
v => v.Split(",",
StringSplitOptions.None).ToList() // ao ler
);
// Converter DateTime para Unix:
e.Property(p => p.Data)
.HasConversion<long>();As Value Conversions transformam tipos ao guardar/ler. O primeiro lambda converte para a BD, o segundo converte da BD. Útil para listas, JSON, etc.
Computed columns
e.Property(p => p.NomeCompleto)
.HasComputedColumnSql("[Nome] + ' ' + [Apelido]");
// Stored (persistida):
e.Property(p => p.Total)
.HasComputedColumnSql("[Preco] * [Quantidade]",
stored: true);O HasComputedColumnSql() cria colunas calculadas na BD. Com stored: true o valor é persistido (melhor para leitura frequente).
Concurrency Token
[Timestamp]
public byte[] RowVersion { get; set; }
// Ou Fluent API:
e.Property(p => p.RowVersion)
.IsRowVersion();
// Token manual:
e.Property(p => p.Versao)
.IsConcurrencyToken();O IsRowVersion() cria um token de concorrência otimista. O SQL Server atualiza automaticamente. Se outro utilizador modificar, lança DbUpdateConcurrencyException.
Fluent API: propriedades
modelBuilder.Entity<Produto>(e =>
{
e.ToTable("Produtos");
e.Property(p => p.Nome)
.HasMaxLength(200)
.IsRequired();
e.Property(p => p.Preco)
.HasPrecision(18, 2);
e.HasIndex(p => p.Nome).IsUnique();
});A Fluent API oferece mais controlo: ToTable() define o nome, HasPrecision() para decimais, HasIndex() cria índices. Tem prioridade sobre annotations.
Shadow Properties
modelBuilder.Entity<Post>()
.Property<DateTime>("UltimaModificacao");
// Aceder:
context.Entry(post)
.Property("UltimaModificacao").CurrentValue = DateTime.Now;
// Em queries:
context.Posts.OrderBy("UltimaModificacao");As Shadow Properties existem na BD mas não na classe. Úteis para auditoria. Acede via Entry().Property() ou string em queries.
Owned types (Value Objects)
public class Morada
{
public string Rua { get; set; }
public string Cidade { get; set; }
public string CodigoPostal { get; set; }
}
// Configuração:
e.OwnsOne(c => c.Morada, m =>
{
m.Property(x => x.Rua).HasMaxLength(200);
m.Property(x => x.Cidade).HasMaxLength(100);
});Os Owned Types mapeiam value objects na mesma tabela. Não têm identidade própria. Ideal para Morada, Coordenadas, etc.
Backing fields
public class Pedido
{
private readonly List<Item> _itens = new();
public IReadOnlyCollection<Item> Itens
=> _itens.AsReadOnly();
public void AddItem(Item item) => _itens.Add(item);
}
// Config:
e.Metadata.FindNavigation(nameof(Pedido.Itens))!
.SetPropertyAccessMode(PropertyAccessMode.Field);Os Backing Fields permitem encapsular coleções. O EF acede ao campo privado diretamente. Expõe como IReadOnlyCollection e controla mutações via métodos.
Chave primária composta
public class ItemPedido
{
public int PedidoId { get; set; }
public int ProdutoId { get; set; }
public int Quantidade { get; set; }
}
// Fluent API:
modelBuilder.Entity<ItemPedido>()
.HasKey(i => new { i.PedidoId, i.ProdutoId });Uma chave composta usa múltiplas colunas como PK. Define com HasKey() passando um tipo anónimo. Não pode ser auto-incremento.
Índices
// Índice simples:
e.HasIndex(p => p.Email).IsUnique();
// Índice composto:
e.HasIndex(p => new { p.Nome, p.Cidade });
// Com filtro (SQL Server):
e.HasIndex(p => p.Email)
.HasFilter("[Email] IS NOT NULL");O HasIndex() cria índices para performance. O IsUnique() impede duplicados. Índices compostos aceitam tipos anónimos. O HasFilter() cria índices filtrados.
Table-per-Hierarchy (TPH)
public abstract class Animal
{
public int Id { get; set; }
public string Nome { get; set; }
}
public class Cao : Animal { public string Raca { get; set; } }
public class Gato : Animal { public bool Interior { get; set; } }
// Discriminator:
e.HasDiscriminator<string>("Tipo")
.HasValue<Cao>("cao")
.HasValue<Gato>("gato");O TPH (padrão) guarda toda a hierarquia numa tabela com Discriminator. Simples e rápido. Colunas de subtipos ficam nullable.
Keyless entities (Views)
public class RelatorioVendas
{
public string Mes { get; set; }
public decimal Total { get; set; }
}
// Config:
modelBuilder.Entity<RelatorioVendas>(e =>
{
e.HasNoKey();
e.ToView("vw_RelatorioVendas");
});
// Query:
var dados = await context.Set<RelatorioVendas>().ToListAsync();As Keyless Entities mapeiam views ou queries sem PK. Usa HasNoKey() e ToView(). São read-only e não suportam tracking.
CRUD
Criar (Insert)
var cliente = new Cliente
{
Nome = "Ana",
Email = "ana@mail.com"
};
context.Clientes.Add(cliente);
await context.SaveChangesAsync();
// ID gerado automaticamente:
Console.WriteLine(cliente.Id);O Add() marca a entidade como nova e o SaveChangesAsync() executa o INSERT. O ID auto-incremento é preenchido após guardar.
Eliminar (Delete)
var cliente = await context.Clientes.FindAsync(1); context.Clientes.Remove(cliente); await context.SaveChangesAsync(); // Múltiplos: context.Clientes.RemoveRange(inativos); await context.SaveChangesAsync();
O Remove() marca para eliminação e o SaveChangesAsync() executa o DELETE. O RemoveRange() remove múltiplos de uma vez.
Upsert (AddOrUpdate)
// EF Core não tem upsert nativo — padrão manual:
var existente = await context.Produtos
.FirstOrDefaultAsync(p => p.Sku == sku);
if (existente != null)
{
existente.Preco = novoPreco;
}
else
{
context.Produtos.Add(new Produto { Sku = sku, Preco = novoPreco });
}
await context.SaveChangesAsync();O EF Core não tem upsert nativo. O padrão é buscar e decidir entre update ou insert. Alternativa: ExecuteUpdate + fallback para insert.
SaveChanges com validação
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
// Erro de BD (constraint, etc.)
Console.WriteLine(ex.InnerException?.Message);
}
catch (DbUpdateConcurrencyException ex)
{
// Conflito de concorrência
var entry = ex.Entries.Single();
}O SaveChangesAsync() lança DbUpdateException em erros de BD e DbUpdateConcurrencyException em conflitos. Trata ambos para robustez.
Ler por ID (Find)
// Por chave primária (usa cache local): var cliente = await context.Clientes.FindAsync(1); // Com chave composta: var item = await context.Itens.FindAsync(pedidoId, produtoId); // Retorna null se não existir
O FindAsync() busca por chave primária. Verifica primeiro o Change Tracker (cache local) antes de ir à BD. Retorna null se não existir.
AddRange (múltiplos)
var produtos = new List<Produto>
{
new() { Nome = "Teclado", Preco = 49.99m },
new() { Nome = "Rato", Preco = 29.99m },
new() { Nome = "Monitor", Preco = 299.99m },
};
context.Produtos.AddRange(produtos);
await context.SaveChangesAsync();O AddRange() adiciona múltiplas entidades de uma vez. Mais eficiente que Add() em loop porque gera um único batch de INSERTs.
Attach e estados
// Attach (estado Unchanged): context.Clientes.Attach(cliente); // Marcar como modificado: context.Entry(cliente).State = EntityState.Modified; // Estados possíveis: // Detached, Unchanged, Added, Modified, Deleted
O Attach() liga uma entidade desconectada ao contexto. O Entry().State define o estado manualmente. Útil em APIs stateless.
Find vs FirstOrDefault
// Find: usa cache local, não gera SQL se já rastreado
var c1 = await context.Clientes.FindAsync(1);
// FirstOrDefault: sempre vai à BD
var c2 = await context.Clientes
.FirstOrDefaultAsync(c => c.Id == 1);
// Find não aceita IncludeO FindAsync() verifica o cache local primeiro (mais rápido). O FirstOrDefaultAsync() gera sempre SQL. O Find não suporta Include().
Ler com condições
// Primeiro com condição:
var c = await context.Clientes
.FirstOrDefaultAsync(c => c.Email == email);
// Único (lança exceção se múltiplos):
var c2 = await context.Clientes
.SingleAsync(c => c.Id == 1);
// Todos:
var todos = await context.Clientes.ToListAsync();O FirstOrDefaultAsync() retorna o primeiro ou null. O SingleAsync() lança exceção se houver mais de um. O ToListAsync() traz todos.
ExecuteUpdate (EF Core 7+)
int afetados = await context.Clientes
.Where(c => c.Ativo == false)
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.Estado, "arquivado")
.SetProperty(c => c.ArquivadoEm, DateTime.Now)
);O ExecuteUpdateAsync() (EF Core 7+) atualiza em massa sem carregar entidades. Gera um UPDATE SQL direto. Retorna o número de linhas afetadas.
Change Tracker
// Ver alterações pendentes:
var entries = context.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified);
foreach (var entry in entries)
{
Console.WriteLine(entry.Entity.GetType().Name);
foreach (var prop in entry.Properties)
{
if (prop.IsModified)
Console.WriteLine($" {prop.Metadata.Name}: {prop.OriginalValue} -> {prop.CurrentValue}");
}
}O Change Tracker deteta alterações automaticamente. O Entries() lista entidades rastreadas e o Properties mostra valores originais vs atuais.
Atualizar (Update)
var cliente = await context.Clientes.FindAsync(1); cliente.Nome = "Ana Silva"; cliente.Email = "ana.silva@mail.com"; await context.SaveChangesAsync(); // Ou attach + estado: context.Clientes.Update(cliente); await context.SaveChangesAsync();
Com tracking ativo, basta modificar as propriedades e chamar SaveChangesAsync(). O Update() marca todas as propriedades como modificadas.
ExecuteDelete (EF Core 7+)
int removidos = await context.Logs
.Where(l => l.Data < DateTime.Now.AddYears(-1))
.ExecuteDeleteAsync();O ExecuteDeleteAsync() elimina em massa sem carregar. Gera um DELETE SQL direto. Muito mais rápido que RemoveRange() para milhares de registos.
DetectChanges manual
// Desativar auto-detect (performance em bulk): context.ChangeTracker.AutoDetectChangesEnabled = false; // Detetar manualmente: context.ChangeTracker.DetectChanges(); // Limpar tracking: context.ChangeTracker.Clear();
O AutoDetectChangesEnabled = false desativa a deteção automática (melhor em operações bulk). O Clear() limpa o tracker sem guardar.
Consultas (LINQ)
Where (filtrar)
var ativos = await context.Clientes
.Where(c => c.Ativo && c.Cidade == "Lisboa")
.ToListAsync();
// Condições múltiplas:
var resultado = await context.Produtos
.Where(p => p.Preco > 10 && p.Preco < 100)
.Where(p => p.Stock > 0)
.ToListAsync();O Where() filtra com expressões lambda. Encadeia múltiplos Where() (AND implícito). Traduz para WHERE SQL.
Any / All
bool existe = await context.Clientes
.AnyAsync(c => c.Email == email);
bool todosAtivos = await context.Pedidos
.AllAsync(p => p.Estado == "concluido");
// Equivalente SQL: EXISTS / NOT EXISTSO AnyAsync() verifica existência (gera EXISTS). O AllAsync() verifica se todos cumprem a condição. Mais eficiente que Count() > 0.
AsNoTracking
var produtos = await context.Produtos
.AsNoTracking()
.Where(p => p.Ativo)
.ToListAsync();
// Ou global:
context.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;O AsNoTracking() desativa o rastreamento (só leitura). Mais rápido e menos memória. Ideal para queries de leitura. Pode definir globalmente.
Conditional (ternário no SQL)
var resultado = await context.Produtos
.Select(p => new
{
p.Nome,
Classificacao = p.Preco > 100 ? "caro" : "barato"
})
.ToListAsync();
// Gera: CASE WHEN Preco > 100 THEN 'caro' ELSE 'barato' ENDExpressões ternárias no Select() traduzem para CASE WHEN no SQL. Útil para classificações e formatações na query.
OrderBy / ThenBy
var ordenados = await context.Produtos
.OrderBy(p => p.Categoria)
.ThenByDescending(p => p.Preco)
.ToListAsync();
// Descendente:
var recentes = await context.Clientes
.OrderByDescending(c => c.CriadoEm)
.ToListAsync();O OrderBy() ordena ascendente e OrderByDescending() descendente. O ThenBy() adiciona ordenação secundária.
Paginação (Skip/Take)
int pagina = 2, tamanho = 10;
var itens = await context.Produtos
.OrderBy(p => p.Nome)
.Skip((pagina - 1) * tamanho)
.Take(tamanho)
.ToListAsync();
// Total para UI:
int total = await context.Produtos.CountAsync();O Skip() salta registos e o Take() limita. Requer OrderBy() para ordem consistente. Gera OFFSET/FETCH no SQL Server.
Contains / In
var ids = new List<int> { 1, 2, 3, 5 };
var produtos = await context.Produtos
.Where(p => ids.Contains(p.Id))
.ToListAsync();
// Gera: WHERE Id IN (1, 2, 3, 5)O Contains() com uma lista gera WHERE IN (...). Útil para filtrar por múltiplos valores. A lista pode vir de outra query.
AsSplitQuery
var clientes = await context.Clientes
.Include(c => c.Pedidos)
.ThenInclude(p => p.Itens)
.AsSplitQuery()
.ToListAsync();O AsSplitQuery() divide o Include em múltiplas queries SQL em vez de um JOIN gigante. Evita cartesian explosion com múltiplas coleções.
Select (projeção)
// Tipo anónimo:
var nomes = await context.Clientes
.Select(c => new { c.Nome, c.Email })
.ToListAsync();
// DTO:
var dtos = await context.Produtos
.Select(p => new ProdutoDto(p.Nome, p.Preco))
.ToListAsync();O Select() projeta para tipos anónimos ou DTOs. Traz apenas as colunas necessárias — muito mais eficiente que carregar a entidade completa.
Distinct
var categorias = await context.Produtos
.Select(p => p.Categoria)
.Distinct()
.ToListAsync();
// DistinctBy (EF Core 6+):
var unicos = await context.Clientes
.DistinctBy(c => c.Cidade)
.ToListAsync();O Distinct() remove duplicados. O DistinctBy() (EF Core 6+) remove por campo específico. Traduz para SELECT DISTINCT.
First / Single / Last
// Primeiro (ou exceção):
var primeiro = await context.Produtos
.OrderBy(p => p.Preco)
.FirstAsync();
// Único (exceção se 0 ou >1):
var unico = await context.Clientes
.SingleAsync(c => c.Email == email);
// Versões "OrDefault" retornam null:
var talvez = await context.Produtos
.FirstOrDefaultAsync(p => p.Id == 999);O FirstAsync() lança se vazio. O SingleAsync() lança se não for exatamente um. As versões OrDefault retornam null em vez de lançar.
AsQueryable dinâmico
IQueryable<Produto> query = context.Produtos;
if (!string.IsNullOrEmpty(nome))
query = query.Where(p => p.Nome.Contains(nome));
if (precoMin > 0)
query = query.Where(p => p.Preco >= precoMin);
if (categoria != null)
query = query.Where(p => p.Categoria == categoria);
var resultado = await query.ToListAsync();Constrói queries dinamicamente com IQueryable. Cada Where() só é aplicado se o filtro existir. O SQL final só inclui as condições ativas.
Agregações
int total = await context.Produtos.CountAsync(); decimal max = await context.Produtos.MaxAsync(p => p.Preco); decimal min = await context.Produtos.MinAsync(p => p.Preco); decimal soma = await context.Produtos.SumAsync(p => p.Preco); double media = await context.Produtos.AverageAsync(p => p.Preco);
Métodos de agregação assíncronos: CountAsync(), MaxAsync(), MinAsync(), SumAsync(), AverageAsync(). Geram SQL agregado.
GroupBy
var grupos = await context.Produtos
.GroupBy(p => p.Categoria)
.Select(g => new
{
Categoria = g.Key,
Total = g.Count(),
PrecoMedio = g.Average(p => p.Preco)
})
.ToListAsync();O GroupBy() agrupa e o Select() projeta os agregados. Gera GROUP BY com COUNT/AVG no SQL.
Like / String methods
var resultados = await context.Produtos
.Where(p => p.Nome.Contains("rato"))
.ToListAsync();
// StartsWith / EndsWith:
var porPrefixo = await context.Clientes
.Where(c => c.Nome.StartsWith("Ana"))
.ToListAsync();
// EF.Functions.Like:
var like = await context.Produtos
.Where(p => EF.Functions.Like(p.Nome, "%port%"))
.ToListAsync();O Contains(), StartsWith() e EndsWith() traduzem para LIKE. O EF.Functions.Like() dá controlo total do padrão.
FromSql (raw queries)
var clientes = await context.Clientes
.FromSql($"SELECT * FROM Clientes WHERE Cidade = {cidade}")
.ToListAsync();
// Compor com LINQ:
var ativos = await context.Clientes
.FromSql($"SELECT * FROM Clientes")
.Where(c => c.Ativo)
.OrderBy(c => c.Nome)
.ToListAsync();O FromSql() (interpolado, seguro) executa SQL raw e permite compor com LINQ. O FromSqlRaw() usa placeholders {0} para parâmetros.
Relações
One-to-Many
public class Cliente
{
public int Id { get; set; }
public List<Pedido> Pedidos { get; set; } = new();
}
public class Pedido
{
public int Id { get; set; }
public int ClienteId { get; set; } // FK
public Cliente Cliente { get; set; }
}A relação One-to-Many usa uma FK (ClienteId) no lado "muitos". As propriedades de navegação (Pedidos, Cliente) permitem aceder aos relacionados.
Include (Eager Loading)
var cliente = await context.Clientes
.Include(c => c.Pedidos)
.ThenInclude(p => p.Itens)
.ThenInclude(i => i.Produto)
.FirstOrDefaultAsync(c => c.Id == 1);O Include() carrega relações eagerly numa só query. O ThenInclude() encadeia sub-relações. Evita N+1 mas pode gerar JOINs grandes.
Filtered Include
var clientes = await context.Clientes
.Include(c => c.Pedidos
.Where(p => p.Estado == "pendente")
.OrderByDescending(p => p.Data)
.Take(5))
.ToListAsync();O Filtered Include (EF Core 5+) aplica Where(), OrderBy() e Take() dentro do Include. Carrega só os relacionados que interessam.
InverseProperty
public class Post
{
public int Id { get; set; }
public int AutorId { get; set; }
public int RevisorId { get; set; }
[ForeignKey("AutorId")]
public User Autor { get; set; }
[ForeignKey("RevisorId")]
public User Revisor { get; set; }
}Quando há múltiplas relações entre as mesmas entidades, usa [ForeignKey] ou HasOne().WithMany().HasForeignKey() para desambiguar qual FK pertence a qual navegação.
One-to-One
public class Pessoa
{
public int Id { get; set; }
public Passaporte Passaporte { get; set; }
}
public class Passaporte
{
public int Id { get; set; }
public int PessoaId { get; set; }
public Pessoa Pessoa { get; set; }
}
// Fluent API:
e.HasOne(p => p.Passaporte)
.WithOne(p => p.Pessoa)
.HasForeignKey<Passaporte>(p => p.PessoaId);A relação One-to-One usa HasOne().WithOne() e define a FK com HasForeignKey<T>(). A FK fica numa das tabelas.
Multiple Includes
var pedido = await context.Pedidos
.Include(p => p.Cliente)
.Include(p => p.Itens)
.ThenInclude(i => i.Produto)
.Include(p => p.Cupao)
.FirstOrDefaultAsync(p => p.Id == id);Múltiplos Include() carregam várias relações do mesmo nível. Cada um adiciona um JOIN ou split query. Combina com ThenInclude() para profundidade.
ForeignKey sem navegação
// Só FK, sem propriedade de navegação:
public class Pedido
{
public int Id { get; set; }
public int ClienteId { get; set; }
}
// Config:
e.HasOne<Cliente>()
.WithMany()
.HasForeignKey(p => p.ClienteId);Podes ter só a FK sem propriedade de navegação. O HasOne<T>().WithMany() sem argumentos define a relação. Útil para simplificar o model.
Carregar relacionados (query)
// Join manual com Select:
var dados = await context.Pedidos
.Select(p => new
{
PedidoId = p.Id,
ClienteNome = p.Cliente.Nome,
TotalItens = p.Itens.Count
})
.ToListAsync();Em vez de Include(), projeta com Select() e acede às navegações diretamente. Gera JOINs otimizados e traz só o necessário.
Many-to-Many
public class Estudante
{
public int Id { get; set; }
public List<Curso> Cursos { get; set; } = new();
}
public class Curso
{
public int Id { get; set; }
public List<Estudante> Estudantes { get; set; } = new();
}
// EF Core 5+ cria a tabela join automaticamenteA relação Many-to-Many (EF Core 5+) cria a tabela join implicitamente. Basta ter List<T> em ambos os lados. Sem FK explícita.
Explicit Loading
var cliente = await context.Clientes.FindAsync(1);
// Carregar coleção:
await context.Entry(cliente)
.Collection(c => c.Pedidos)
.LoadAsync();
// Carregar referência:
await context.Entry(pedido)
.Reference(p => p.Cliente)
.LoadAsync();O carregamento explícito usa Entry().Collection().LoadAsync() para coleções e Reference().LoadAsync() para navegação única. Carrega sob demanda.
Delete behavior
e.HasOne(p => p.Cliente)
.WithMany(c => c.Pedidos)
.HasForeignKey(p => p.ClienteId)
.OnDelete(DeleteBehavior.Cascade);
// Opções:
// Cascade → elimina filhos
// Restrict → impede eliminação
// SetNull → FK fica null
// ClientCascade → cascade no clienteO OnDelete() define o comportamento ao eliminar o pai. Cascade elimina filhos, Restrict impede, SetNull anula a FK.
Many-to-Many com payload
public class EstudanteCurso
{
public int EstudanteId { get; set; }
public int CursoId { get; set; }
public DateTime DataInscricao { get; set; }
public Estudante Estudante { get; set; }
public Curso Curso { get; set; }
}
// Config:
e.HasOne(sc => sc.Estudante)
.WithMany(s => s.EstudanteCursos)
.HasForeignKey(sc => sc.EstudanteId);Quando a tabela join tem dados extra (payload), cria uma entidade explícita. Define as duas relações HasOne().WithMany() com as FKs.
Lazy Loading (Proxies)
// Instalar: Microsoft.EntityFrameworkCore.Proxies
options.UseLazyLoadingProxies();
// Propriedades devem ser virtual:
public class Cliente
{
public virtual List<Pedido> Pedidos { get; set; }
}
// Acesso automático:
var cliente = await context.Clientes.FindAsync(1);
var pedidos = cliente.Pedidos; // query automáticaO Lazy Loading carrega relações automaticamente ao aceder. Requer UseLazyLoadingProxies() e propriedades virtual. Cuidado com N+1.
Self-referencing
public class Categoria
{
public int Id { get; set; }
public string Nome { get; set; }
public int? PaiId { get; set; }
public Categoria Pai { get; set; }
public List<Categoria> Filhos { get; set; } = new();
}
// Config:
e.HasOne(c => c.Pai)
.WithMany(c => c.Filhos)
.HasForeignKey(c => c.PaiId);Relações self-referencing ligam uma entidade a si própria (árvores, hierarquias). A FK é nullable para a raiz. Usa HasOne().WithMany() na mesma classe.
Migrações
Instalar CLI tools
dotnet tool install --global dotnet-ef dotnet tool update --global dotnet-ef # Verificar: dotnet ef --version
As dotnet-ef tools são globais. Instala com dotnet tool install --global. Necessário para criar e aplicar migrations via CLI.
Gerar script SQL
dotnet ef migrations script # De uma migração específica: dotnet ef migrations script Mig1 Mig2 # Idempotente (para produção): dotnet ef migrations script --idempotent -o deploy.sql
O migrations script gera SQL para execução manual em produção. O --idempotent verifica o que já foi aplicado. O -o guarda em ficheiro.
SQL custom na migration
protected override void Up(MigrationBuilder mb)
{
mb.Sql(@"
UPDATE Clientes
SET NomeCompleto = Nome + ' ' + Apelido
WHERE NomeCompleto IS NULL
");
mb.Sql("CREATE INDEX IX_Custom ON Clientes(Cidade)");
}O mb.Sql() executa SQL arbitrário na migration. Útil para transformações de dados, índices custom ou operações não suportadas pelo builder.
Criar migração
dotnet ef migrations add InitialCreate dotnet ef migrations add AdicionarCampoEmail dotnet ef migrations add CriarTabelaPedidos # Com projeto separado: dotnet ef migrations add Nome --project Core --startup-project Web
O migrations add compara o modelo com o snapshot e gera ficheiros de migração. Cada migração tem Up() e Down().
Seed data (HasData)
modelBuilder.Entity<Pais>().HasData(
new Pais { Id = 1, Nome = "Portugal" },
new Pais { Id = 2, Nome = "Brasil" },
new Pais { Id = 3, Nome = "Angola" }
);O HasData() insere dados iniciais via migration. Requer IDs explícitos. Os dados são aplicados no Up() e removidos no Down().
Migrações pendentes
// Verificar em código:
var pendentes = await context.Database
.GetPendingMigrationsAsync();
var aplicadas = await context.Database
.GetAppliedMigrationsAsync();
bool existe = await context.Database
.CanConnectAsync();O GetPendingMigrationsAsync() lista migrações por aplicar. O GetAppliedMigrationsAsync() mostra as aplicadas. Útil para health checks e deploy.
Aplicar migrações
dotnet ef database update dotnet ef database update NomeMigracao // Em código (startup): await context.Database.MigrateAsync();
O database update aplica migrações pendentes. Com nome, aplica até uma específica. O MigrateAsync() aplica programaticamente (útil em deploy).
Estrutura de uma migration
public partial class CriarClientes : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.CreateTable("Clientes", table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Nome = table.Column<string>(maxLength: 100),
Email = table.Column<string>(nullable: true)
});
}
protected override void Down(MigrationBuilder mb)
{
mb.DropTable("Clientes");
}
}Cada migration tem Up() (aplicar) e Down() (reverter). O MigrationBuilder oferece CreateTable(), AddColumn(), DropTable(), etc.
Drop e recriar (dev)
// Apagar BD inteira: await context.Database.EnsureDeletedAsync(); // Recriar sem migrations (só modelo): await context.Database.EnsureCreatedAsync(); // Com migrations: await context.Database.MigrateAsync();
O EnsureDeleted() + MigrateAsync() recria tudo. O EnsureCreated() cria sem migrations (não recomendado em produção). Usa em dev/testes.
Reverter migração
# Reverter BD para migração anterior: dotnet ef database update NomeAnterior # Remover última migração (não aplicada): dotnet ef migrations remove # Reverter tudo: dotnet ef database update 0
O database update NomeAnterior executa o Down(). O migrations remove elimina o ficheiro da última migração não aplicada. O update 0 reverte tudo.
Operações na migration
mb.AddColumn<string>("Clientes", "Telefone",
maxLength: 20, nullable: true);
mb.DropColumn("Clientes", "CampoAntigo");
mb.RenameColumn("Clientes", "Nome", "NomeCompleto");
mb.AlterColumn<decimal>("Produtos", "Preco",
precision: 18, scale: 2);
mb.CreateIndex("IX_Produtos_Nome", "Produtos", "Nome");Operações disponíveis: AddColumn(), DropColumn(), RenameColumn(), AlterColumn(), CreateIndex(), AddForeignKey().
Migrations em equipa
# Se a migration de outro dev conflita: dotnet ef migrations remove # Corrigir o modelo e recriar: dotnet ef migrations add NomeCorrigido # Nunca editar migrations já aplicadas em produção # Criar sempre uma nova migration
Em equipa, nunca edites migrations já aplicadas. Se conflita, remove com migrations remove e recria. O ModelSnapshot deve estar sempre em sync.
Recursos Avançados
Transações explícitas
using var transacao =
await context.Database.BeginTransactionAsync();
try
{
context.Clientes.Add(novo);
await context.SaveChangesAsync();
context.Logs.Add(log);
await context.SaveChangesAsync();
await transacao.CommitAsync();
}
catch
{
await transacao.RollbackAsync();
throw;
}O BeginTransactionAsync() inicia uma transação explícita. O CommitAsync() confirma e o RollbackAsync() reverte tudo se algo falhar.
ExecuteSqlRaw (sem entidades)
int afetados = await context.Database
.ExecuteSqlRawAsync(
"UPDATE Produtos SET Preco = Preco * 1.1 WHERE Categoria = {0}",
categoria
);
// Interpolado:
int n = await context.Database
.ExecuteSql($"DELETE FROM Logs WHERE Data < {cutoff}");O ExecuteSqlRawAsync() executa SQL sem mapear entidades. Retorna o número de linhas afetadas. Ideal para bulk updates e operações diretas.
Compiled Queries
private static readonly Func<AppDbContext, int, Task<Cliente?>>
_getCliente = EF.CompileAsyncQuery(
(AppDbContext ctx, int id) =>
ctx.Clientes.FirstOrDefault(c => c.Id == id));
// Uso:
var cliente = await _getCliente(context, 1);As Compiled Queries pré-compilam a expressão LINQ. Eliminam o overhead de parsing em queries repetidas. Útil em hot paths com milhares de execuções.
Database facade
bool existe = await context.Database.CanConnectAsync(); string nome = context.Database.GetDbConnection().Database; // Criar/eliminar: await context.Database.EnsureCreatedAsync(); await context.Database.EnsureDeletedAsync(); // Transação: await context.Database.BeginTransactionAsync();
O Database facade dá acesso a operações de BD: CanConnectAsync(), EnsureCreatedAsync(), BeginTransactionAsync(), ExecuteSqlRawAsync().
TransactionScope
using var scope = new TransactionScope(
TransactionScopeAsyncFlowOption.Enabled);
await context1.SaveChangesAsync();
await context2.SaveChangesAsync();
scope.Complete();O TransactionScope coordena transações entre múltiplos contextos ou recursos. O Complete() faz commit. Sem ele, faz rollback automático.
Global Query Filters
// Config (soft delete):
modelBuilder.Entity<Post>()
.HasQueryFilter(p => !p.Eliminado);
// Multi-tenant:
modelBuilder.Entity<Produto>()
.HasQueryFilter(p => p.TenantId == _tenantId);
// Ignorar filtro:
var todos = context.Posts
.IgnoreQueryFilters()
.ToList();Os Global Query Filters aplicam condições automáticas a todas as queries. Ideais para soft delete e multi-tenancy. O IgnoreQueryFilters() desativa.
Temporal Tables (EF Core 6+)
// Config:
e.ToTable("Clientes", b => b.IsTemporal());
// Query histórica:
var dados = await context.Clientes
.TemporalAsOf(DateTime.Now.AddDays(-7))
.Where(c => c.Id == 1)
.ToListAsync();
// Todas as versões:
var historico = await context.Clientes
.TemporalAll()
.Where(c => c.Id == 1)
.ToListAsync();As Temporal Tables (SQL Server) guardam histórico automático. O IsTemporal() ativa e TemporalAsOf()/TemporalAll() consultam versões passadas.
Retry com resiliência
// Estratégia de execução personalizada:
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.ExecutionStrategy(d =>
new SqlServerRetryingExecutionStrategy(
d, maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: new List<int> { 4060 }
));
});O ExecutionStrategy personaliza a resiliência. O SqlServerRetryingExecutionStrategy retenta em erros transitórios. Adiciona códigos de erro custom.
Raw SQL (FromSqlRaw)
var clientes = await context.Clientes
.FromSqlRaw(
"SELECT * FROM Clientes WHERE Cidade = {0}",
cidade
).ToListAsync();
// Interpolado (mais seguro):
var resultado = await context.Clientes
.FromSql($"SELECT * FROM Clientes WHERE Id = {id}")
.ToListAsync();O FromSqlRaw() usa placeholders {0} para parâmetros (previne SQL injection). O FromSql() interpolado é mais legível e igualmente seguro.
Optimistic Concurrency
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var dbValues = await entry.GetDatabaseValuesAsync();
// Resolver conflito:
entry.OriginalValues.SetValues(dbValues);
await context.SaveChangesAsync(); // retry
}A concorrência otimista deteta conflitos via RowVersion. O catch de DbUpdateConcurrencyException permite resolver: manter valores, usar os da BD ou merge.
JSON columns (EF Core 7+)
public class Produto
{
public int Id { get; set; }
public DetalheJson Detalhe { get; set; }
}
public class DetalheJson
{
public string Cor { get; set; }
public List<string> Tags { get; set; }
}
// Config:
e.OwnsOne(p => p.Detalhe, d => d.ToJson());
// Query:
var resultados = await context.Produtos
.Where(p => p.Detalhe.Cor == "azul")
.ToListAsync();O ToJson() (EF Core 7+) mapeia owned types como coluna JSON. Permite queries dentro do JSON. Suportado em SQL Server e PostgreSQL.
Stored Procedures
var resultados = await context.Clientes
.FromSqlRaw("EXEC sp_GetClientes @p0", cidade)
.ToListAsync();
// Com retorno:
var total = await context.Database
.ExecuteSqlRawAsync("EXEC sp_AtualizarStock @p0", produtoId);O FromSqlRaw() chama stored procedures com EXEC. O ExecuteSqlRawAsync() executa sem retorno de entidades (INSERT/UPDATE/DELETE).
Interceptors
public class AuditInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>>
SavingChangesAsync(
DbContextEventData data,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var entries = data.Context!.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified);
// registar auditoria
return base.SavingChangesAsync(data, result, ct);
}
}
// Registar:
options.AddInterceptors(new AuditInterceptor());Os Interceptors interceptam eventos do EF (save, query, connection). O SaveChangesInterceptor permite auditoria, logging ou validação antes de guardar.
Entity States e Audit
public override async Task<int> SaveChangesAsync(
CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added)
entry.Property("CriadoEm").CurrentValue = DateTime.Now;
if (entry.State == EntityState.Modified)
entry.Property("ModificadoEm").CurrentValue = DateTime.Now;
}
return await base.SaveChangesAsync(ct);
}Sobrescreve SaveChangesAsync() para auditoria automática. Verifica EntityState (Added/Modified) e define timestamps. Padrão comum em produção.
Performance e Boas Práticas
Evitar N+1
// MAU (N+1):
var pedidos = await context.Pedidos.ToListAsync();
foreach (var p in pedidos)
Console.WriteLine(p.Cliente.Nome); // query por pedido!
// BOM:
var pedidos = await context.Pedidos
.Include(p => p.Cliente)
.ToListAsync();O problema N+1 ocorre quando acedes a navegações sem Include. Gera 1 query + N queries. Resolve com Include() ou projeção Select().
Split queries para Includes
// Single query (JOIN gigante):
var dados = await context.Clientes
.Include(c => c.Pedidos).ThenInclude(p => p.Itens)
.ToListAsync();
// Split query (múltiplas queries):
var dados = await context.Clientes
.Include(c => c.Pedidos).ThenInclude(p => p.Itens)
.AsSplitQuery()
.ToListAsync();O AsSplitQuery() divide em múltiplas queries SQL. Evita cartesian explosion (duplicação de linhas em JOINs múltiplos). Melhor com coleções grandes.
Testes com SQLite
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
using var context = new AppDbContext(options);
await context.Database.EnsureCreatedAsync();
// Testar com SQL real (suporta mais features)O SQLite in-memory é mais fiel que InMemory (suporta FKs, SQL real). Mantém a conexão aberta durante o teste. O EnsureCreatedAsync() cria o esquema.
Índices e performance
// Criar índices para queries frequentes:
e.HasIndex(p => p.Email).IsUnique();
e.HasIndex(p => new { p.Categoria, p.Ativo });
e.HasIndex(p => p.CriadoEm).IsDescending();
// Verificar queries lentas com:
options.LogTo(Console.WriteLine, LogLevel.Information);Cria índices para colunas usadas em WHERE, JOIN e ORDER BY. Compostos para filtros múltiplos. Monitoriza com LogTo() e otimiza com Include/Select.
Projetar em vez de carregar
// MAU (carrega tudo):
var clientes = await context.Clientes.ToListAsync();
var nomes = clientes.Select(c => c.Nome);
// BOM (só o necessário):
var nomes = await context.Clientes
.Select(c => c.Nome)
.ToListAsync();Projeta com Select() para trazer só as colunas necessárias. Reduz memória e tráfego de rede. O SQL gerado usa SELECT com campos específicos.
Padrão Repository
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Remove(T entity);
Task<int> SaveAsync();
}
public class Repository<T> : IRepository<T> where T : class
{
private readonly AppDbContext _context;
public Repository(AppDbContext context) => _context = context;
public async Task<T?> GetByIdAsync(int id)
=> await _context.Set<T>().FindAsync(id);
}O padrão Repository abstrai o acesso a dados. Facilita testes com mocks e desacopla a lógica de negócio do EF. O Set<T>() dá acesso genérico.
DTOs em vez de entidades
// Nunca expor entidades na API:
public record ClienteDto(int Id, string Nome, string Email);
// Mapear na query:
var dtos = await context.Clientes
.Select(c => new ClienteDto(c.Id, c.Nome, c.Email))
.ToListAsync();
return Ok(dtos);Nunca exponhas entidades diretamente na API (ciclos, dados sensíveis). Usa DTOs ou records com Select(). Previne over-posting e serialização circular.
Checklist de boas práticas
// ✓ AsNoTracking() para leitura // ✓ Select() para projeção (não carregar tudo) // ✓ Include() para evitar N+1 // ✓ AsSplitQuery() com múltiplos Includes // ✓ ExecuteUpdate/Delete para bulk (EF 7+) // ✓ Async em todas as operações // ✓ DTOs na API (nunca entidades) // ✓ Migrations em source control // ✓ Índices para queries frequentes // ✓ DbContext com scope curto
Resumo das práticas essenciais: AsNoTracking(), projeção, Include(), async, DTOs, migrations versionadas e índices. Segue isto para código EF Core robusto.
AsNoTracking para leitura
// Leituras que não precisam de update:
var produtos = await context.Produtos
.AsNoTracking()
.Where(p => p.Ativo)
.ToListAsync();
// Ou por contexto:
context.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;O AsNoTracking() elimina o overhead do Change Tracker em queries de leitura. Menos memória, mais rápido. Usa sempre que não vais modificar os dados.
Unit of Work
public interface IUnitOfWork
{
IRepository<Cliente> Clientes { get; }
IRepository<Produto> Produtos { get; }
Task<int> SaveAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IRepository<Cliente> Clientes { get; }
public IRepository<Produto> Produtos { get; }
public async Task<int> SaveAsync()
=> await _context.SaveChangesAsync();
}O Unit of Work coordena múltiplos repositórios numa única transação. O SaveAsync() aplica todas as alterações atomicamente. Complementa o Repository.
Async em todo o lado
// SEMPRE async: var clientes = await context.Clientes.ToListAsync(); var cliente = await context.Clientes.FindAsync(1); await context.SaveChangesAsync(); // NUNCA .Result ou .Wait(): // var c = context.Clientes.First(); // bloqueia thread!
Usa sempre os métodos Async (ToListAsync(), FindAsync(), SaveChangesAsync()). Nunca bloqueies com .Result ou .Wait() — causa deadlocks.
Batch com ExecuteUpdate/Delete
// MAU (carrega + remove um a um):
var logs = await context.Logs
.Where(l => l.Data < cutoff).ToListAsync();
context.Logs.RemoveRange(logs);
await context.SaveChangesAsync();
// BOM (EF Core 7+):
await context.Logs
.Where(l => l.Data < cutoff)
.ExecuteDeleteAsync();O ExecuteDeleteAsync()/ExecuteUpdateAsync() (EF Core 7+) operam em massa sem carregar. Um único SQL em vez de milhares de operações.
Testes com InMemory
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb_" + Guid.NewGuid())
.Options;
using var context = new AppDbContext(options);
context.Clientes.Add(new Cliente { Nome = "Teste" });
await context.SaveChangesAsync();
var cliente = await context.Clientes.FirstAsync();
Assert.Equal("Teste", cliente.Nome);O InMemory provider permite testes sem BD real. Cada teste usa um nome único para isolamento. Não suporta todas as features (ex.: transações).
DbContext de curta duração
// Correto: scope por request (DI padrão) builder.Services.AddDbContext<AppDbContext>(...); // NÃO usar como Singleton: // builder.Services.AddSingleton<AppDbContext>(); // ERRO! // Para background jobs, criar scope: using var scope = app.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
O DbContext não é thread-safe — usa scope por request. Nunca como Singleton. Em background jobs, cria um scope manual com CreateScope().