O Entity Framework Core é o ORM padrão para .NET. Ele mapeia suas classes C# para tabelas do banco de dados, gerencia migrações conforme o esquema evolui e converte expressões LINQ em SQL. Este guia cobre tudo, desde um projeto em branco até padrões prontos para produção.
Cada tópico aqui tem um artigo dedicado com um sample executável e verificado por trás — reunidos em Samples executáveis deste guia no final. Os trechos de código abaixo estão verificados no EF Core 10, e quando este guia faz uma afirmação sobre desempenho, o número vem de um programa em jorgenhoc-org/dotnet-samples que eu mesmo executei — contagens de instruções e números de alocação que você pode reproduzir, não adjetivos. Escrever esses samples mudou várias das minhas próprias recomendações pelo caminho (a maior delas: a projeção vence o Include com mais frequência do que eu costumava afirmar), então o conselho aqui é o que sobreviveu a ser medido.
LINQ (C#)
var users = await context.Users
.Where(u => u.IsActive)
.OrderBy(u => u.LastName)
.ToListAsync();Generated SQL
SELECT [u].[Id], [u].[Email], [u].[FirstName],
[u].[IsActive], [u].[LastName]
FROM [Users] AS [u]
WHERE [u].[IsActive] = 1
ORDER BY [u].[LastName]Where() → WHERE, OrderBy() → ORDER BY. EF Core translates LINQ operators 1:1.
Instalação
Comece com um novo projeto ASP.NET Core e adicione os pacotes do EF Core:
dotnet new webapi -n MyApp
cd MyApp
# Pacotes principais do EF
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer # ou Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Tools # para a CLI de migraçõesPara SQLite (ótimo para desenvolvimento e aplicações pequenas):
dotnet add package Microsoft.EntityFrameworkCore.SqliteDefinindo Suas Entidades
Entidades são classes C# simples. O EF Core usa convenções para inferir nomes de tabelas, chaves primárias e tipos de coluna:
// Models/Product.cs
public class Product
{
public int Id { get; set; } // Convenção: "Id" → chave primária
public required string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public DateTime CreatedAt { get; set; }
// Propriedade de navegação (um para muitos)
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
// Models/Category.cs
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
// Propriedade de navegação de coleção
public List<Product> Products { get; set; } = [];
}Use required em propriedades do tipo string (C# 11+) para exigir valores não nulos em tempo de compilação. O EF Core mapeia required string para uma coluna não nullable automaticamente.
Configurando o DbContext
DbContext é a classe central — ela contém suas propriedades DbSet<T> e gerencia a conexão com o banco de dados:
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configuração com Fluent API (opcional, mas recomendada)
modelBuilder.Entity<Product>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
entity.Property(p => p.Price)
.HasPrecision(18, 2);
entity.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Category>(entity =>
{
entity.Property(c => c.Name)
.HasMaxLength(100)
.IsRequired();
});
}
}Registrando o DbContext
Em Program.cs:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// SQL Server
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Ou SQLite
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db"));
// Ou PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));String de conexão em appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyAppDb;Trusted_Connection=True;"
}
}Migrações
As migrações registram as alterações de esquema como arquivos versionados. Toda vez que você altera suas classes de entidade, você adiciona uma migração.
# Instalar a ferramenta global do EF (configuração única)
dotnet tool install --global dotnet-ef
# Criar a migração inicial
dotnet ef migrations add InitialCreate
# Aplicar as migrações ao banco de dados
dotnet ef database updateIsso gera uma pasta Migrations/ com arquivos como:
// Migrations/20250116120000_InitialCreate.cs
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(maxLength: 200, nullable: false),
Price = table.Column<decimal>(precision: 18, scale: 2, nullable: false),
StockQuantity = table.Column<int>(nullable: false),
CreatedAt = table.Column<DateTime>(nullable: false),
CategoryId = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
table.ForeignKey(
name: "FK_Products_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Products");
migrationBuilder.DropTable(name: "Categories");
}
}Nunca edite arquivos de migração manualmente depois de aplicá-los a qualquer banco de dados. Se precisar fazer uma alteração, adicione uma nova migração em vez disso.
Referência de Comandos de Migração
# Adicionar uma nova migração após alterar entidades
dotnet ef migrations add AddProductDescription
# Atualizar o banco de dados para a migração mais recente
dotnet ef database update
# Reverter para uma migração específica
dotnet ef database update InitialCreate
# Remover a última migração não aplicada
dotnet ef migrations remove
# Gerar script SQL em vez de aplicar diretamente (recomendado para produção)
dotnet ef migrations script --output migration.sqlOperações CRUD
Criar
// Injetar AppDbContext via injeção de construtor
public class ProductService
{
private readonly AppDbContext _db;
public ProductService(AppDbContext db) => _db = db;
public async Task<Product> CreateProductAsync(string name, decimal price, int categoryId)
{
var product = new Product
{
Name = name,
Price = price,
StockQuantity = 0,
CreatedAt = DateTime.UtcNow,
CategoryId = categoryId
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
return product; // O Id é preenchido após SaveChangesAsync
}
}Ler — Consultas Básicas
// Obter todos os produtos
var products = await _db.Products.ToListAsync();
// Obter por chave primária (mais eficiente — usa o índice PK)
var product = await _db.Products.FindAsync(42);
// Obter um único com condição
var product = await _db.Products
.FirstOrDefaultAsync(p => p.Id == 42);
// Obter com dados relacionados (carregamento antecipado)
var productsWithCategory = await _db.Products
.Include(p => p.Category)
.ToListAsync();Ler — Consultas LINQ
// Filtrar
var expensiveProducts = await _db.Products
.Where(p => p.Price > 100)
.OrderBy(p => p.Price)
.ToListAsync();
// Projetar para DTO (evita carregar a entidade completa quando não necessário)
var productDtos = await _db.Products
.Where(p => p.StockQuantity > 0)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
// Paginação
int page = 1, pageSize = 20;
var pagedProducts = await _db.Products
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Contar
var inStockCount = await _db.Products
.CountAsync(p => p.StockQuantity > 0);
// Any / All
bool hasExpensiveItems = await _db.Products.AnyAsync(p => p.Price > 500);Sempre use .Select() para projetar para um DTO quando precisar apenas de um subconjunto de colunas. Carregar entidades completas quando você só precisa de Name e Price desperdiça memória e adiciona colunas SQL desnecessárias.
Atualizar
// Padrão buscar-e-atualizar (mais seguro, gerencia concorrência)
public async Task<bool> UpdatePriceAsync(int productId, decimal newPrice)
{
var product = await _db.Products.FindAsync(productId);
if (product is null) return false;
product.Price = newPrice;
await _db.SaveChangesAsync();
return true;
}
// Atualização em lote (EF Core 7+ ExecuteUpdateAsync — sem carregamento de entidades)
await _db.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, x => x.Price * 0.9m));Excluir
// Buscar e excluir
public async Task<bool> DeleteProductAsync(int productId)
{
var product = await _db.Products.FindAsync(productId);
if (product is null) return false;
_db.Products.Remove(product);
await _db.SaveChangesAsync();
return true;
}
// Exclusão em lote (EF Core 7+ ExecuteDeleteAsync — sem carregamento de entidades)
await _db.Products
.Where(p => p.StockQuantity == 0 && p.CreatedAt < DateTime.UtcNow.AddYears(-2))
.ExecuteDeleteAsync();Relacionamentos
Um para Muitos (configurado acima)
Carregando dados relacionados:
// Carregamento antecipado com Include
var category = await _db.Categories
.Include(c => c.Products)
.FirstOrDefaultAsync(c => c.Id == categoryId);
// Carregamento explícito (carrega a propriedade de navegação sob demanda)
var category = await _db.Categories.FindAsync(categoryId);
await _db.Entry(category!).Collection(c => c.Products).LoadAsync();Muitos para Muitos (EF Core 5+)
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public List<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Post> Posts { get; set; } = [];
}
// O EF Core 5+ cria a tabela de junção automaticamente — nenhuma entidade extra é necessária
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags"));Um para Um
public class User
{
public int Id { get; set; }
public required string Email { get; set; }
public UserProfile? Profile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string? Bio { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
}O Que Cada Estratégia de Carregamento Realmente Custa
"Carregamento antecipado é mais rápido que carregamento lento" é o tipo de afirmação que se repete sem evidência, então eu medi. O sample de N+1 semeia 500 pedidos com 8 itens de linha cada, executa cada estratégia de carregamento contra os mesmos dados e conta as instruções SQL que o EF Core realmente executa:
| Estratégia | Instruções SQL | Pedidos carregados |
|---|---|---|
| Consulta por linha (N+1, uma navegação) | 501 | 500 |
| Consulta por linha (N+1, duas navegações) | 1,001 | 500 |
Include (antecipado, consulta única) | 1 | 500 |
Include + AsSplitQuery() | 2 | 500 |
Projeção com Select() | 1 | 500 |
Reporto contagens em vez de tempos deliberadamente: contagens são reproduzíveis em qualquer máquina e com qualquer provedor relacional, então você pode clonar o sample, executar seu seed.sql e obter exatamente esses números em vez de aceitá-los por confiança. Os tempos dependem de onde seu banco de dados mora — e é precisamente por isso que as contagens importam. Contra o LocalDB, 501 idas e voltas custam milissegundos e o bug se esconde; contra um banco de dados gerenciado em outra região, cada ida e volta paga latência de rede real e o mesmo código leva segundos. A contagem de instruções é o sinal honesto porque não muda quando a latência muda.
Duas coisas nessa tabela me surpreenderam quando a executei pela primeira vez. Primeiro, AsSplitQuery() emitiu duas instruções, não três: Customer é uma navegação de referência, então permanece no JOIN, e só a coleção é separada — um detalhe fácil de perder se você só leu que consultas divididas "emitem uma consulta por include". Segundo, a linha da projeção é a vencedora silenciosa: uma instrução e transfere apenas as colunas que você nomeia. A mudança de maior impacto na maioria das bases de código com EF Core que já revisei é substituir ToList()-e-depois-navegar por uma projeção com Select(); o artigo de um-para-muitos aplica a mesma medição a cada padrão de carregamento de relacionamentos.
Para pegar o N+1 antes da produção, registre as instruções em desenvolvimento (veja Registro de Consultas SQL abaixo) e procure no seu código acessos a propriedades de navegação dentro de loops. O mergulho fundo no N+1 mostra um DbCommandInterceptor que conta consultas em testes de integração e falha o build quando uma requisição excede um limite.
Consultas Sem Rastreamento
Por padrão, o EF Core rastreia as entidades carregadas para detecção de alterações. Para consultas somente leitura, desabilite o rastreamento para melhor desempenho:
// Sem rastreamento para operações de leitura
var products = await _db.Products
.AsNoTracking()
.Where(p => p.Price > 50)
.ToListAsync();
// Configurar globalmente para um DbContext usado apenas para leituras
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);Use .AsNoTracking() em qualquer consulta onde você não vai atualizar as entidades retornadas. Isso ignora a sobrecarga do rastreador de alterações e pode oferecer entre 10 e 30% de melhoria no desempenho para cargas de trabalho intensivas em leitura.
Transações
using var transaction = await _db.Database.BeginTransactionAsync();
try
{
_db.Products.Add(newProduct);
await _db.SaveChangesAsync();
_db.Orders.Add(newOrder);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}SQL Bruto Sem Abrir uma Brecha de Injeção
Às vezes o SQL de que você precisa é mais fácil de escrever do que de arrancar do LINQ. O EF Core suporta isso diretamente — mas a superfície da API se divide em uma metade segura e uma metade afiada, e a diferença é um sufixo:
// SEGURO — FromSql (EF Core 7+) recebe um interpolated string handler.
// O {minPrice} abaixo vira um DbParameter, NÃO concatenação de strings.
var products = await _db.Products
.FromSql($"SELECT * FROM Products WHERE Price > {minPrice}")
.ToListAsync();
// AFIADO — FromSqlRaw com interpolação de strings é uma vulnerabilidade de injeção.
// Nunca faça isso com entrada do usuário:
var products = await _db.Products
.FromSqlRaw($"SELECT * FROM Products WHERE Name = '{userInput}'") // NÃO FAÇA ISSO
.ToListAsync();Esse primeiro exemplo parece que deveria ser vulnerável — lê-se exatamente como interpolação de strings — e é por isso que não pedi a ninguém para acreditar nele. O sample de SQL bruto executa uma tentativa de injeção real através das duas formas contra um banco de dados de verdade: a mesma entrada de ataque ' OR '1'='1 vaza todas as 12 linhas semeadas quando concatenada no FromSqlRaw, e corresponde a 0 linhas através da API interpolada, porque ali ela é apenas um valor de parâmetro. A mesma entrada, a um sufixo de distância — e o sample verifica os dois resultados com asserções entre suas 19 verificações. O artigo de SQL bruto percorre a demo, além de SqlQueryRaw<T> para tipos de resultado não mapeados e como o SQL bruto se compõe com LINQ: empilhar .Where() e .Include() sobre um SELECT bruto ainda executa como uma única instrução, com o EF envolvendo seu SQL em uma subconsulta (visível em ToQueryString()) — embora um EXEC não possa ser envolvido dessa forma, então resultados de stored procedures não se compõem.
A segurança vive no tipo em tempo de compilação, não em como o código parece. FromSql aceita apenas um interpolated string handler, então seus buracos {...} viram DbParameters. Monte o mesmo texto primeiro como uma string comum e a interpolação já terá acontecido — e a única sobrecarga que a aceita é a raw. Se você precisar montar SQL dinamicamente, use FromSqlRaw com objetos de parâmetro explícitos e nunca concatene entrada do usuário no texto SQL.
Filtros Globais de Consulta: Soft Delete e Multi-Tenancy
Um filtro global de consulta é uma cláusula Where que o modelo aplica a toda consulta contra uma entidade — o mecanismo padrão para soft delete e multi-tenancy em nível de linha:
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public bool IsDeleted { get; set; } // Flag de exclusão lógica (soft delete)
}
// Em OnModelCreating — toda consulta em Products agora recebe WHERE IsDeleted = 0
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
// Ignorar o filtro para telas de administração/restauração
var everything = await _db.Products.IgnoreQueryFilters().ToListAsync();O conceito cabe em um parágrafo; os casos extremos são onde as equipes se queimam, e são a razão pela qual o sample de filtros globais de consulta verifica 22 comportamentos separados com asserções em vez de narrá-los. Três dessas verificações valem a pena conhecer antes de colocar um filtro em produção. Os filtros se aplicam através de Include() e das propriedades de navegação automaticamente, que é justamente o objetivo — mas corta dos dois lados, porque IgnoreQueryFilters() remove todos os filtros da entidade, isolamento de tenant incluído, não só o de soft delete que você pretendia contornar. Há uma armadilha de fixup: depois de uma consulta com IgnoreQueryFilters(), as linhas excluídas ficam rastreadas, e uma consulta filtrada posterior no mesmo contexto as anexa aos seus resultados mesmo assim via fixup de navegação — o sample reproduz isso, e a correção é um contexto novo ou AsNoTracking(). E a mais cara: construir um filtro de tenant com Expression.Constant(tenantProvider) grava o provedor da primeira requisição no modelo em cache, o que o sample demonstra entregando as linhas do tenant A para uma requisição do tenant B. O artigo completo cobre cada uma com a asserção que a prova, além dos filtros nomeados do EF Core 10 (IgnoreQueryFilters(["SoftDelete"])) que finalmente tornam seguro o bypass seletivo.
Erros Comuns a Evitar
Nunca chame .ToList() antes de filtrar. _db.Products.ToList().Where(...) carrega TODAS as linhas na memória e depois filtra em C#. Sempre filtre com .Where() antes de .ToList() ou .ToListAsync() para que o filtro seja executado no SQL.
Não compartilhe DbContext entre threads. DbContext não é thread-safe. No ASP.NET Core, use o tempo de vida com escopo (scoped) padrão — uma instância por requisição HTTP.
Habilite o registro de dados sensíveis apenas em desenvolvimento. Adicione .EnableSensitiveDataLogging() para ver os valores dos parâmetros nos logs de consulta, mas nunca em produção.
Registro de Consultas SQL
Para ver o SQL gerado pelo EF Core (essencial para depurar desempenho):
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}
});Aplicando Migrações em Tempo de Execução
Para aplicações em contêineres, aplique as migrações na inicialização em vez de como uma etapa separada:
// Program.cs
var app = builder.Build();
// Aplicar automaticamente as migrações pendentes na inicialização
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.Run();A migração automática na inicialização funciona bem para equipes pequenas. Para equipes grandes ou implantações sem tempo de inatividade, execute as migrações como uma etapa separada antes de implantar a nova versão da aplicação.
Quanto Custa o ORM? EF Core vs Dapper, Medido
Em algum momento alguém em toda equipe pergunta se o EF Core é "lento demais", e a resposta deveria ser uma medição, não um humor. Comparei o EF Core com o Dapper e o ADO.NET puro usando BenchmarkDotNet no mesmo esquema e nas mesmas consultas; o cabeçalho completo do ambiente, as colunas de StdDev e a metodologia estão no artigo EF Core vs Dapper, mas estas linhas sustentam o argumento:
| Cenário | Método | Média | Alocado |
|---|---|---|---|
| SELECT de 1,000 linhas | EF Core (com rastreamento) | 2,210.5 µs | 1,079.7 KB |
EF Core AsNoTracking | 1,309.6 µs | 427.4 KB | |
| Dapper | 793.9 µs | 201.5 KB | |
| Busca de uma linha por PK | EF Core FirstOrDefaultAsync | 370.4 µs | 74.7 KB |
| Dapper | 151.9 µs | 6.8 KB |
Duas leituras honestas dessa tabela. Sim, o Dapper é aproximadamente 2–3× mais rápido em leituras e aloca uma fração da memória — se sua carga de trabalho é dominada por endpoints de leitura de alto volume, essa diferença é real. Mas veja o que uma única linha de EF Core recupera: AsNoTracking() sozinho reduziu a consulta de 1,000 linhas de 2,210 µs para 1,310 µs e cortou as alocações em 60%, fechando a maior parte da distância até o Dapper sem abrir mão de LINQ, das migrações ou do rastreamento de alterações onde você ainda os quer. O padrão em que me firmei para aplicações maiores é híbrido: EF Core para as escritas e qualquer coisa que toque o rastreador de alterações, Dapper para o punhado de caminhos de leitura que o profiling — não a intuição — mostra que estão quentes. A comparação completa inclui projeções com JOIN, inserções e uma tabela de decisão para escolher por carga de trabalho em vez de por religião.
Samples executáveis deste guia
Cada seção acima é ampliada em um artigo focado com um projeto de console que você pode clonar e executar — cada um verifica suas afirmações com asserções ou reporta contagens deterministas de instruções SQL, então os números se reproduzem na sua máquina em vez de serem aceitos por confiança. Todos estão em jorgenhoc-org/dotnet-samples.
| Seção deste guia | Artigo dedicado | Sample |
|---|---|---|
| Migrações | Guia de migrations | ef-core-migrations-walkthrough — quatro arquivos de migration reais |
| Relacionamentos → um-para-muitos | Relacionamentos um-para-muitos | ef-core-one-to-many — estratégias de carregamento por contagem de instruções |
| Relacionamentos → muitos-para-muitos | Relacionamentos muitos-para-muitos | ef-core-many-to-many — junção implícita + entidade de junção explícita |
Sem rastreamento, SQL bruto, ExecuteUpdate/ExecuteDelete | Consultas SQL bruto | ef-core-raw-sql — parametrização vs uma injeção real |
Consultas de leitura e desempenho de Include | O problema N+1 | ef-core-n-plus-one — a enxurrada, e depois as soluções |
| Quando abandonar o ORM | EF Core vs Dapper | ef-core-vs-dapper — trade-offs medidos |
| Filtros de consulta (soft delete, multi-tenancy) | Filtros globais de consulta | ef-core-global-query-filters — 22 verificações com asserções |
Próximos Passos
Com o básico consolidado, explore:
- Desempenho: Consultas compiladas, processamento em lote e o fluxo de migrações para produção
- Relacionamentos avançados: Entidades próprias, herança por hierarquia de tabela
- Interceptores: Log de auditoria, automação de exclusão lógica (combina com os filtros globais de consulta)
- Recursos recentes do EF Core (8 a 10): Colunas JSON, tipos complexos e filtros de consulta nomeados