Você pergunta em LINQ e altera objetos do domínio normalmente; o Entity Framework descobre sozinho o SQL que precisa para ler e para gravar.
"Dapper: SQL Seu, Sem a Cerimônia" terminou listando o que continuava sendo responsabilidade nossa: escrever cada INSERT e UPDATE, saber o que mudou num objeto e montar relacionamentos a partir de linhas planas. O Entity Framework Core assume exatamente esse trabalho. Ele é o ORM oficial do .NET, reescrito do zero em 2016 junto com o .NET Core, a partir da experiência de oito anos do Entity Framework original. Com ele, as consultas LINQ de "LINQ, Parte I" deixam de rodar sobre listas na memória: são traduzidas para SQL e executadas no banco. Nesta aula conectamos o domínio da Livraria ao EF Core, com a hierarquia de produtos, o dinheiro e o encapsulamento que construímos na Fase 2.
As peças do EF Core
Três conceitos formam o núcleo:
- Entidades: as classes do domínio que o EF persiste, como
LivroImpressoeEbook. São classes comuns: não precisam herdar de nada nem ter atributos. DbContext: a sessão de trabalho com o banco. Ele sabe quais entidades existem, como elas mapeiam para tabelas, executa consultas e acompanha as mudanças nos objetos carregados.DbSet<T>: o ponto de partida das consultas sobre uma entidade, algo como uma tabela vista como coleção.
Os pacotes, no projeto de dados:
dotnet add src/Livraria.Dados package Microsoft.EntityFrameworkCore.Sqlite
dotnet add src/Livraria.Dados package EFCore.NamingConventions
O primeiro é o EF Core com o provedor do SQLite, construído sobre o Microsoft.Data.Sqlite da aula de ADO.NET. O segundo, mantido pela comunidade, traduz os nomes do C# (PrecoCentavos) para a convenção de bancos (preco_centavos) automaticamente.
Um ajuste no domínio: o Id agora vem do banco
Desde "Classes e Objetos", os produtos geravam o próprio Id com um contador estático, e desde lá apontamos suas fragilidades: ele recomeça a cada execução e não conhece os produtos já gravados. Com um banco de dados, a responsabilidade passa para quem sabe gerá-lo corretamente. Em Produto, o contador sai de cena:
public int Id { get; private set; } // atribuído pelo banco ao salvar
Um produto recém-criado tem Id zero até ser gravado. Ao salvar, o EF Core recebe do banco o valor gerado e o atribui à propriedade, usando o set privado, que continua inacessível para o resto do programa. É uma das poucas mudanças que o domínio precisa sofrer, e ela o deixa melhor, não pior.
O contexto
using Microsoft.EntityFrameworkCore;
namespace Livraria.Dados;
public sealed class LivrariaContext(DbContextOptions<LivrariaContext> options) : DbContext(options)
{
public DbSet<Produto> Produtos => Set<Produto>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(LivrariaContext).Assembly);
}
O contexto recebe as opções (qual banco, qual cadeia de conexão) pelo construtor primário. Essa escolha permite configurá-lo de fora, e é o que a API da próxima fase vai fazer com injeção de dependência. OnModelCreating é onde se descreve o mapeamento, e ApplyConfigurationsFromAssembly procura no projeto todas as classes de configuração, uma por entidade, mantendo o contexto pequeno.
Configurando o mapeamento
O EF Core segue convenções: uma propriedade Id vira a chave primária, propriedades públicas com get e set viram colunas, e tipos anuláveis viram colunas que aceitam nulo. Tudo o que foge da convenção é descrito numa classe de configuração, com a API fluente:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class ProdutoConfiguracao : IEntityTypeConfiguration<Produto>
{
public void Configure(EntityTypeBuilder<Produto> builder)
{
builder.ToTable("produtos");
builder.HasKey(p => p.Id);
builder.Property(p => p.Titulo).IsRequired().HasMaxLength(200);
builder.Property(p => p.Autor).IsRequired().HasMaxLength(120);
builder.HasIndex(p => p.Titulo);
builder.Property(p => p.Preco)
.HasColumnName("preco_centavos")
.HasConversion(
reais => (long)Math.Round(reais * 100),
centavos => centavos / 100m);
builder.HasDiscriminator<string>("tipo")
.HasValue<LivroImpresso>("impresso")
.HasValue<Ebook>("ebook")
.HasValue<Audiolivro>("audiolivro");
}
}
public sealed class LivroImpressoConfiguracao : IEntityTypeConfiguration<LivroImpresso>
{
public void Configure(EntityTypeBuilder<LivroImpresso> builder) =>
builder.Ignore(l => l.LimiteEstoqueBaixo);
}
Três decisões merecem atenção, porque cada uma resolve um problema que já conhecemos.
O dinheiro. Em "SQL Essencial com SQLite" vimos que o SQLite não tem tipo decimal exato. O provedor do EF Core para SQLite até consegue guardar decimal, gravando-o como texto, mas não consegue ordenar nem comparar esses valores no banco. Um conversor de valor resolve de forma elegante: o domínio continua trabalhando com decimal em reais, e o banco guarda inteiros em centavos. As duas funções do conversor dizem como ir e como voltar. E o EF Core aplica o conversor também às consultas: um filtro p.Preco < 40m vira preco_centavos < 4000 no SQL.
A hierarquia. HasDiscriminator mapeia a herança de "Herança: Quando um Tipo É uma Versão Especializada de Outro" para a mesma estratégia de tabela única que usamos à mão na aula de SQL: uma coluna tipo indica a classe de cada linha, e as colunas próprias de cada tipo ficam nulas quando não se aplicam. Ao ler, o EF Core cria automaticamente um LivroImpresso ou um Ebook conforme o valor da coluna. O polimorfismo sobrevive à ida e à volta ao banco.
O que não é dado. LimiteEstoqueBaixo é uma propriedade pública com set, e pela convenção viraria uma coluna. Mas ela é uma configuração de comportamento, e não um dado do livro, então a excluímos com Ignore. O EF Core só mapeia propriedades. O evento EstoqueBaixo, a trava de "Concorrência" e as propriedades calculadas sem set, como Disponivel e Formato, ficam de fora naturalmente.
Como o EF Core respeita o encapsulamento
Uma dúvida legítima: nossos produtos têm propriedades com set privado, validações nos construtores e nenhum construtor vazio. Como o EF Core cria os objetos ao ler do banco?
Ele é mais esperto do que parece. Ao materializar uma entidade, o EF Core procura um construtor cujos parâmetros correspondam, pelo nome, às propriedades mapeadas. O construtor LivroImpresso(string titulo, string autor, decimal preco, int pesoGramas, int estoque) serve perfeitamente: o EF Core o chama com os valores lidos, e as validações rodam. As propriedades que não estão no construtor, como o Id, são preenchidas depois, diretamente no campo interno, sem passar pelo set. Nossa classe não precisou abrir nenhuma porta dos fundos.
Criando o banco e gravando
Para começar rapidamente, EnsureCreated cria o banco e as tabelas a partir do modelo, se ainda não existirem. (Na próxima aula ele será substituído pelas migrations, a forma profissional de evoluir o esquema.)
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
DbContextOptions<LivrariaContext> opcoes = new DbContextOptionsBuilder<LivrariaContext>()
.UseSqlite("Data Source=livraria-ef.db")
.UseSnakeCaseNamingConvention()
.LogTo(Console.WriteLine, LogLevel.Information)
.Options;
await using (var contexto = new LivrariaContext(opcoes))
{
await contexto.Database.EnsureCreatedAsync();
if (!await contexto.Produtos.AnyAsync())
{
contexto.Produtos.AddRange(
new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10),
new Ebook("Dom Casmurro", "Machado de Assis", 19.90m, tamanhoMb: 2.4),
new LivroImpresso("Vidas Secas", "Graciliano Ramos", 38.00m, pesoGramas: 250, estoque: 4),
new Ebook("São Bernardo", "Graciliano Ramos", 22.90m, tamanhoMb: 1.6),
new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 2));
await contexto.SaveChangesAsync();
}
}
Add e AddRange não gravam nada: eles registram os objetos no contexto como "novos". É o SaveChangesAsync que gera os INSERTs, envia tudo ao banco dentro de uma transação, que o EF Core abre sozinho, recebe os identificadores gerados e os atribui às propriedades Id. Depois do SaveChanges, cada produto já tem o seu Id real.
A opção LogTo faz o EF Core imprimir cada comando SQL que executa. Durante o aprendizado, e na depuração de problemas de desempenho, é indispensável. Você passa a ver o SQL que as suas consultas LINQ produzem.
Consultando com LINQ
As consultas são escritas com os mesmos operadores de "LINQ, Parte I", sobre o DbSet:
await using var contexto = new LivrariaContext(opcoes);
List<Produto> baratos = await contexto.Produtos
.Where(p => p.Preco < 40m)
.OrderBy(p => p.Preco)
.ToListAsync();
O log mostra o SQL gerado, algo como:
SELECT p.id, p.autor, p.preco_centavos, p.tipo, p.titulo, p.estoque, p.peso_gramas, p.tamanho_mb, ...
FROM produtos AS p
WHERE p.preco_centavos < 4000
ORDER BY p.preco_centavos
Como isso é possível, se o LINQ trabalhava com delegates que executam código C#? A resposta é que o DbSet não implementa só IEnumerable<T>, mas IQueryable<T>. Com IQueryable, a lambda p => p.Preco < 40m não é compilada como um método executável. Ela vira uma árvore de expressão, uma estrutura de dados que descreve o código ("a propriedade Preco de p, comparada com a constante 40"), algo parecido com o que os source generators de "Source Generators" enxergam do seu código. O provedor do EF Core percorre essa árvore e a traduz para o SQL do banco em uso. O filtro, a ordenação e até o conversor de centavos são aplicados no banco.
A execução adiada de "LINQ, Parte II" vale integralmente aqui: nada vai ao banco até que a consulta seja materializada com ToListAsync, FirstAsync, CountAsync, AnyAsync ou um await foreach. Os operadores terminados em Async são as versões assíncronas do EF Core para os operadores que executam.
E as consultas conhecem a hierarquia. OfType<LivroImpresso>() vira uma condição sobre o discriminador:
LivroImpresso dom = await contexto.Produtos
.OfType<LivroImpresso>()
.SingleAsync(l => l.Titulo == "Dom Casmurro");
SELECT ... FROM produtos AS p
WHERE p.tipo = 'impresso' AND p.titulo = 'Dom Casmurro'
LIMIT 2
Repare no LIMIT 2: para verificar a semântica de Single, "exatamente um", o EF Core pede até dois resultados. Se vierem dois, lança a exceção.
Rastreamento de mudanças: gravar sem escrever UPDATE
Agora o recurso que mais diferencia um ORM completo do Dapper. Os objetos carregados por uma consulta ficam rastreados pelo contexto: ele guarda uma cópia dos valores originais de cada um. Altere os objetos normalmente, usando os métodos do domínio, e peça para salvar:
dom.Vender(2); // a regra de negócio de sempre, com validação e evento
dom.Preco = 64.90m; // o set com validação de "Propriedades e Encapsulamento"
await contexto.SaveChangesAsync();
No SaveChanges, o EF Core compara os valores atuais com os originais, descobre que só estoque e preco_centavos mudaram e gera um UPDATE com exatamente essas duas colunas:
UPDATE produtos SET estoque = @p0, preco_centavos = @p1
WHERE id = @p2;
Nenhuma linha de SQL escrita, nenhuma regra de negócio duplicada: a venda passou pela mesma validação de sempre, e a gravação reflete exatamente o que mudou. Para remover, contexto.Produtos.Remove(entidade) seguido de SaveChangesAsync.
O ciclo de vida do contexto
Um DbContext é uma unidade de trabalho de vida curta: carregue o que precisa, faça as alterações, salve e descarte. Três razões sustentam essa regra:
- Ele não é seguro entre threads. Duas operações simultâneas sobre o mesmo contexto corrompem seu estado interno, pelos motivos de "Concorrência". O EF Core detecta a maioria desses casos e lança exceção.
- O rastreamento cresce. Cada entidade carregada fica guardada no contexto. Um contexto usado durante horas acumularia milhares de objetos, com o vazamento de memória descrito em "Memória no .NET".
- Os dados envelhecem. Um objeto rastreado há horas não reflete as mudanças feitas por outros usuários no banco.
Numa API, cada requisição recebe o seu próprio contexto, e é exatamente assim que o ASP.NET o configura por padrão, como veremos na Fase 6.
O que não pode ser traduzido
Nem todo código C# tem um equivalente em SQL. Um método seu dentro de um Where não pode ser traduzido, porque o banco não sabe executá-lo:
static bool EhClassico(Produto p) => p.Titulo.StartsWith("Dom") || p.Autor == "Machado de Assis";
var classicos = await contexto.Produtos.Where(p => EhClassico(p)).ToListAsync();
// InvalidOperationException: a expressão não pôde ser traduzida
Um detalhe para reproduzir o erro: EhClassico precisa ser método de uma classe. Solto no Program.cs, junto das instruções de nível superior, ele vira uma função local, e o erro chega antes, na compilação: CS8110, porque uma árvore de expressão não pode citar função local.
O EF Core recusa com uma mensagem clara, em vez de, silenciosamente, trazer a tabela inteira para a memória e filtrar lá. As versões 1 e 2 do próprio EF Core faziam essa avaliação no cliente, avisando só no log, com consequências de desempenho catastróficas; desde a 3.0 ela é recusada. A correção é escrever a condição diretamente com operações que o provedor conhece (StartsWith, Contains, comparações, operadores lógicos) ou, se a lógica precisar mesmo de C#, trazer primeiro um subconjunto já filtrado e aplicar o resto na memória, de forma explícita.
Juntando tudo: o domínio persistido
await using (var contexto = new LivrariaContext(opcoes))
{
int digitais = await contexto.Produtos.CountAsync(p => p is Ebook);
Console.WriteLine($"E-books no catálogo: {digitais}");
Produto maisCaro = await contexto.Produtos.OrderByDescending(p => p.Preco).FirstAsync();
Console.WriteLine($"Mais caro: {maisCaro.Titulo} ({maisCaro.Formato}), {maisCaro.Preco:C}");
LivroImpresso sertao = await contexto.Produtos.OfType<LivroImpresso>()
.SingleAsync(l => l.Titulo == "Grande Sertão: Veredas");
try
{
sertao.Vender(3);
await contexto.SaveChangesAsync();
}
catch (EstoqueInsuficienteException ex)
{
Console.WriteLine($"Venda recusada: {ex.Message}");
}
}
p is Ebook dentro de uma consulta é traduzido para tipo = 'ebook': o pattern matching de tipo, de "Enums e Pattern Matching", funciona também na tradução. A venda de três exemplares de Grande Sertão, que tem dois, é recusada pela regra do domínio, antes de qualquer SQL, e o SaveChanges nem chega a ser chamado. Perceba o que mudou em relação ao Dapper: o código trabalha com os objetos do domínio da Fase 2, com toda a sua proteção, e o banco virou um detalhe de infraestrutura.
O modelo desta aula, porém, ainda é simples demais para um sistema real. O autor continua sendo uma string repetida em cada produto: um erro de digitação cria um "autor" novo, e renomear um autor exige alterar todos os seus livros. Os pedidos, com seus itens, ainda não existem no banco. E o EnsureCreated só sabe criar o banco do zero: no dia em que o modelo ganhar uma coluna nova, não há como atualizar um banco que já tem dados sem apagá-lo. Relacionamentos entre entidades e a evolução controlada do esquema são os próximos passos.
Fontes e leituras recomendadas
- Entity Framework Core (Microsoft Learn): https://learn.microsoft.com/ef/core/. É o ponto de partida da documentação oficial, com os primeiros passos e a arquitetura.
- Criando e configurando um modelo: https://learn.microsoft.com/ef/core/modeling/. Cobre as convenções, a API fluente e as classes de configuração.
- Conversões de valor: https://learn.microsoft.com/ef/core/modeling/value-conversions. Explica conversores personalizados e os limites do seu uso em consultas.
- Herança no EF Core: https://learn.microsoft.com/ef/core/modeling/inheritance. Compara as estratégias de tabela por hierarquia, por tipo e por tipo concreto.
- Tipos de entidade com construtores: https://learn.microsoft.com/ef/core/modeling/constructors. Mostra como o EF Core usa construtores com parâmetros ao materializar entidades.
- Limitações do provedor SQLite: https://learn.microsoft.com/ef/core/providers/sqlite/limitations. Explica, entre outras coisas, o tratamento de
decimalque motivou o conversor desta aula.
Exercícios
Exercício 1
Com o contexto da aula, escreva consultas que devolvam: a quantidade de livros impressos com estoque abaixo de 5, e os títulos distintos do catálogo em ordem alfabética. Observe o SQL gerado pelo log.
Ver resposta
✓ Resposta:
int poucoEstoque = await contexto.Produtos
.OfType<LivroImpresso>()
.CountAsync(l => l.Estoque < 5);
List<string> titulos = await contexto.Produtos
.Select(p => p.Titulo)
.Distinct()
.OrderBy(t => t)
.ToListAsync();
A primeira vira algo como SELECT COUNT(*) FROM produtos AS p WHERE p.tipo = 'impresso' AND p.estoque < 5: o banco devolve só um número, e nenhum produto é carregado. Com os dados da aula, o resultado é 2 (Vidas Secas e Grande Sertão). A segunda vira SELECT DISTINCT p.titulo FROM produtos AS p ORDER BY p.titulo, e só os títulos trafegam, sem os demais campos. Em ambas, a projeção e a agregação acontecem no banco, a vantagem de IQueryable sobre carregar tudo e aplicar o LINQ na memória. Ler o SQL gerado é o melhor hábito para quem começa com o EF Core: ele mostra exatamente quanto trabalho foi para o banco.
Exercício 2
A consulta abaixo lança InvalidOperationException. Explique o motivo e reescreva-a de duas formas: uma totalmente traduzível e outra que filtre parte no banco e parte na memória, de forma explícita.
static string Normalizar(string s) => s.Trim().ToLowerInvariant().Replace("ã", "a");
var r = await contexto.Produtos.Where(p => Normalizar(p.Titulo).Contains("sertao")).ToListAsync();
Ver resposta
✓ Resposta: Normalizar é um método C# comum (de uma classe; solto no Program.cs, como função local, nem compilaria, com CS8110). Na árvore de expressão, ele aparece como "uma chamada ao método Normalizar", e o provedor não tem como traduzi-lo para SQL, porque o banco não pode executar código C#. O EF Core recusa em vez de trazer tudo para a memória em silêncio. A forma traduzível usa só operações que o provedor conhece:
var r1 = await contexto.Produtos
.Where(p => p.Titulo.ToLower().Contains("sertão"))
.ToListAsync();
ToLower e Contains viram funções do SQL (lower e instr, no SQLite). A remoção de acentos, porém, não tem tradução simples no SQLite. A forma híbrida filtra no banco o que dá e faz o resto, explicitamente, na memória:
var candidatos = await contexto.Produtos
.Where(p => p.Titulo.ToLower().Contains("sert"))
.ToListAsync(); // banco: filtro amplo
var r2 = candidatos.Where(p => Normalizar(p.Titulo).Contains("sertao")).ToList(); // memória: refinamento
O ToListAsync no meio marca a fronteira: antes dele, IQueryable e SQL; depois, IEnumerable e C#. Esse limite visível é a diferença entre uma decisão consciente e a avaliação oculta das versões antigas. Buscas sem acento de verdade exigem recursos do banco, como agrupamentos (collations) e busca textual, e os veremos no PostgreSQL.
Exercício 3
Carregue um livro com o contexto, altere o preço e salve, observando o SQL. Depois repita carregando o livro com .AsNoTracking(). O que acontece no segundo caso, e por quê?
Ver resposta
✓ Resposta:
var livro = await contexto.Produtos.OfType<LivroImpresso>().FirstAsync(l => l.Titulo == "Vidas Secas");
livro.Preco = 42.00m;
await contexto.SaveChangesAsync(); // UPDATE produtos SET preco_centavos = @p0 WHERE id = @p1
var livro2 = await contexto.Produtos.AsNoTracking().OfType<LivroImpresso>().FirstAsync(l => l.Titulo == "Vidas Secas");
livro2.Preco = 45.00m;
await contexto.SaveChangesAsync(); // nenhum comando: nada é gravado
No primeiro caso, o livro é rastreado: o contexto guardou os valores originais e, no SaveChanges, detecta a mudança de preço e gera um UPDATE só daquela coluna. No segundo, AsNoTracking diz ao contexto para não acompanhar os objetos devolvidos. O livro é um objeto comum, desligado do contexto, e alterá-lo não tem efeito nenhum no banco: o SaveChanges não encontra nada para gravar e não emite nenhum comando. Por que existe essa opção? Rastrear tem custo de memória e processamento, e numa consulta que só lê, como uma listagem de catálogo, esse custo é desperdício. Consultas somente leitura com AsNoTracking são uma das otimizações mais importantes do EF Core, e voltam na aula de desempenho. O risco é justamente o deste exercício: alterar um objeto não rastreado e esperar que a mudança seja gravada.
Exercício 4
Um colega guarda um único LivrariaContext num campo estático e o usa em toda a aplicação, inclusive em várias tarefas simultâneas. Liste os problemas que isso causa.
Ver resposta
✓ Resposta: São pelo menos quatro. Primeiro, concorrência: o DbContext não é seguro entre threads. Duas consultas simultâneas corrompem seu estado interno, e o EF Core normalmente detecta e lança InvalidOperationException ("uma segunda operação foi iniciada neste contexto antes que uma operação anterior fosse concluída"). Quando não detecta, o resultado são dados errados. Segundo, memória: todo objeto carregado com rastreamento fica guardado no contexto para sempre, e o campo estático é uma raiz, como no exercício 3 de "Memória no .NET", então nada disso é recolhido. Terceiro, dados envelhecidos: se outro usuário alterar um produto no banco, o contexto continua devolvendo a versão que já tinha carregado, porque consultas por chave consultam primeiro as entidades já rastreadas. Quarto, mudanças misturadas: um SaveChanges numa parte do programa grava também as alterações pendentes que outra parte fez e ainda não queria confirmar. A regra é criar um contexto por unidade de trabalho, usá-lo e descartá-lo. Numa API, um por requisição. Criar contextos é barato. Compartilhá-los é caro.
Exercício 5
O que aconteceria se a linha builder.Ignore(l => l.LimiteEstoqueBaixo); fosse removida? A mudança seria perigosa?
Ver resposta
✓ Resposta: Como LimiteEstoqueBaixo é uma propriedade pública com get e set, a convenção do EF Core a mapearia para uma coluna limite_estoque_baixo na tabela produtos, que ficaria nula para e-books e audiolivros, pela estratégia de tabela única. O programa continuaria funcionando, mas com efeitos que merecem reflexão. O limite passaria a ser persistido: alterá-lo num livro e salvar gravaria o novo valor, e ele sobreviveria entre execuções. Se isso for desejado (um limite configurável por livro, definido pelo gerente), mapear é a escolha certa, e a decisão deixa de ser um erro. Se não for, o banco passa a guardar um detalhe de comportamento que ninguém pediu, e mudar o valor padrão no código deixaria de ter efeito sobre os livros já gravados, porque eles carregariam o valor antigo do banco. A lição é que, com um ORM baseado em convenções, toda propriedade pública é candidata a coluna, e decidir o que é dado persistente e o que é comportamento em memória é uma escolha de modelagem, não um detalhe.