MÓDULO 03 · CONCEITO 08 DE 8

ORMs e suas armadilhas

N+1, lazy loading, leaky abstractions. Quando o ORM acelera, quando atrapalha, e o critério para usar SQL puro nas queries críticas.

Tempo de leitura ~22 min Pré-requisito Todos anteriores do módulo Próximo Módulo 04 — Concorrência

ORM é a sigla mais carregada da engenharia de software depois de OOP. Object-Relational Mapping nasceu da observação de que modelos relacionais e modelos orientados a objetos são paradigmas distintos — o "impedance mismatch" descrito por Michael Stonebraker e formalizado em David Maier nos anos 80. Querer mapear um e outro automaticamente parece sensato; tem sido tentado há quatro décadas. As tentativas geraram ferramentas excelentes (Hibernate, EF Core, SQLAlchemy) e uma reputação ambivalente: ORMs aceleram CRUD trivial e arruínam queries críticas com a mesma facilidade.

Ted Neward, em 2006, publicou "The Vietnam of Computer Science" — ensaio comparando o problema do ORM ao envolvimento militar americano: parecia gerenciável, virou impasse de décadas, sem saída clara. A imagem é exagerada, mas a observação central é válida: tentar abstrair completamente o relacional debaixo do objeto é ambição que tem custo de vazamento crônico.

Este conceito não defende nem ataca ORMs. É sobre quando eles ajudam, quais armadilhas são previsíveis, e como sêniores experientes desenham acesso a banco numa stack moderna — onde frequentemente ORM convive com SQL puro, cada um onde brilha.

O que ORM resolve, de verdade

Antes das críticas, vale lembrar o que ORMs entregam quando bem-usados:

Para a esmagadora maioria de aplicações típicas, ORM bem-usado é vitória líquida. As armadilhas aparecem em queries específicas e em cenários onde a abstração custa mais do que entrega.

N+1 — a armadilha que sempre volta

O nome surgiu em discussões de Hibernate há vinte anos e continua sendo o problema mais comum em código com ORM. O cenário: você lista pedidos, e para cada pedido acessa o cliente.

# Python / SQLAlchemy ingênuo
pedidos = session.query(Pedido).all()
for p in pedidos:
    print(p.cliente.nome)  # BOOM: uma query por pedido

O ORM materializa p.cliente com lazy loading: cada acesso vira um SELECT * FROM clientes WHERE id = ?. 1 query para listar pedidos + N queries para os clientes. Em uma página com 100 pedidos, 101 round-trips ao banco — onde 2 bastariam.

As soluções variam por ORM, mas a ideia é a mesma — explicitar o JOIN ou pré-carregar com uma única query adicional:

Detectar N+1 — não confie em "olhar o código"

Em apps reais, N+1 se esconde em código longe de onde é gerado: serializadores que acessam relações, métodos de domínio que fazem .Items.Count() sem ter carregado items. A defesa: logging de SQL em desenvolvimento e contadores em testes.

armadilha clássica

Adicionar .Include(...) ou joinedload em todas as relações por precaução — vira o oposto: queries gigantescas que carregam objetos enormes onde só era preciso nome do cliente. ORM bem-usado é seletivo: aqui precisamos do cliente junto, aqui não. Lazy é problema; eager indiscriminado também.

Lazy loading — quando atrapalha mais do que ajuda

Lazy loading parece elegante: o objeto é carregado sob demanda, "só quando precisar". Em prática, é fonte de bugs sutis:

A prática moderna emergente: desabilitar lazy loading como default. EF Core 6+ exige opt-in explícito. SQLAlchemy 2.0 desencoraja relações lazy fora de scope. Em vez disso, especifique cada query com a "shape" exata de dado que precisa.

Quando ORM gera SQL ruim — e como decidir reescrever

O LINQ ou query builder do ORM não conhece todos os recursos do banco específico, e nem sempre gera o SQL que um humano escreveria. Casos típicos:

Agregações complexas

"Top 5 clientes por total gasto este mês, com nome e número de pedidos." LINQ tenta:

db.Pedidos
    .Where(p => p.CriadoEm >= mesAtual)
    .GroupBy(p => p.ClienteId)
    .Select(g => new {
        ClienteId = g.Key,
        Total = g.Sum(p => p.Valor),
        Count = g.Count()
    })
    .OrderByDescending(x => x.Total)
    .Take(5)
    .Join(db.Clientes, x => x.ClienteId, c => c.Id,
          (x, c) => new { c.Nome, x.Total, x.Count });

O SQL gerado pode ser eficiente, ou pode ter subqueries desnecessárias, joins esquisitos, projeção redundante. EXPLAIN ANALYZE ajuda — se o plano é ruim, escreva a query crua:

SELECT c.nome, SUM(p.valor) AS total, COUNT(*) AS qtde
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
WHERE p.criado_em >= $1
GROUP BY c.id, c.nome
ORDER BY total DESC
LIMIT 5;

Cinco linhas, plano claro, performance previsível. ORM ainda tem lugar — para os 20 endpoints de CRUD. Esses 5 endpoints de analytics ficam em SQL puro. Sem culpa.

Recursos específicos do banco

Window functions, CTEs recursivas, JSONB operators, UPSERT com ON CONFLICT, full-text search — ORMs frequentemente expõem subset, e muitas vezes você precisa de raw SQL para algo que o banco faz nativamente. Postgres em particular tem dezenas de recursos que LINQ traduz mal ou não traduz.

Bulk operations

UPDATE pedidos SET status='pago' WHERE id IN (...) em uma query. ORM ingênuo tenta carregar 10k pedidos, modificar cada um, salvar — explode em RAM e queries. EF Core 7+ tem ExecuteUpdate; SQLAlchemy 2.0 tem execute(update()). Mas em casos assim, escrever direto raramente é pior.

O modelo híbrido — ORM + SQL no mesmo projeto

A escolha sênior raramente é "100% ORM" ou "100% SQL". É híbrido: ORM para CRUD trivial e operações transacionais simples; SQL puro para queries críticas, agregações, recursos específicos do banco. A regra prática:

  1. Se a operação é "carregar entidade, modificar, salvar" e envolve uma tabela ou poucas, ORM. Você ganha tracking, tipagem, código limpo.
  2. Se a operação é leitura analítica, agregação, filtro complexo, ou usa recursos PG-específicos — SQL puro.
  3. Para operações de bulk, SQL puro com execute, ou recursos modernos do ORM (ExecuteUpdate, upsert).

A sklearn da divisão: ORM cuida do "command side" (mutações, com tracking); SQL puro cuida do "query side" (leituras complexas, com plano controlado). É o esqueleto de CQRS sem precisar adotar CQRS formalmente.

sqlc, jOOQ, Dapper — alternativas type-safe sem ORM

Categoria intermediária: ferramentas que dão tipagem a SQL puro sem o overhead de ORM completo.

A vantagem dessa categoria é que você fica próximo do SQL — sabe exatamente o que executa — com benefícios de tipagem. Para times com forte cultura de banco, é frequentemente melhor que ORM completo.

O custo da abstração — o que o ORM esconde de você

Cada camada de abstração tem custo cognitivo. Com ORM, você escreve em uma linguagem (LINQ, query builder, ActiveRecord) e o ORM gera SQL. Em troca de não escrever SQL, você precisa saber:

Argumento honesto a favor de SQL direto (com sqlc-like type-safety): você só precisa saber SQL. O esforço cognitivo vai todo para entender Postgres, não Postgres+EF+padrões de EF. Para times que escrevem queries complexas como rotina, essa simplicidade vale.

Padrões que evitam dor

Repository pattern com queries explícitas

Não exponha DbContext ou Session pelos andares da aplicação. Encapsule em repositórios cuja interface declara cada operação: BuscarPedidoPorId(id), ListarPedidosDoCliente(clienteId, paginacao), RelatorioVendasMensais(periodo). Cada método tem contrato claro; implementação pode usar ORM ou SQL puro conforme conveniente. Caller não vê.

DTOs por caso de uso

Não devolva entidades do domínio para a camada externa. Mapeie para DTO específico do caso de uso. Isso desacopla schema do contrato externo, permite mudar mapeamento sem quebrar API, e força você a buscar exatamente o que precisa (sem carregar relações desnecessárias por inércia).

Logging de SQL em ambiente de desenvolvimento

Sempre. Não apenas "log queries lentas". Toda query, em desenvolvimento. Você vai ver coisas que assustam — queries duplicadas, joins inesperados, lazy loads escondidos. É a forma mais barata de catar problema antes de ir para produção.

Testes que verificam contagem de queries

Para endpoints críticos, escreva teste que afirma "este endpoint executa exatamente N queries". Quando alguém adiciona uma relação inadvertidamente carregada, o teste falha. Defesa em profundidade contra regressão de performance.

Comparativo nas três stacks

C# — EF Core 9 + Dapper para queries críticas
// EF para CRUD com tracking
public async Task<Pedido?> BuscarAsync(long id) =>
    await _ctx.Pedidos
        .Include(p => p.Itens)  // explícito, deliberado
        .FirstOrDefaultAsync(p => p.Id == id);

public async Task CriarAsync(Pedido p) {
    _ctx.Pedidos.Add(p);
    await _ctx.SaveChangesAsync();
}

// Dapper para query analítica
public async Task<List<TopCliente>> TopClientesDoMesAsync(DateTime mes) {
    using var conn = await _dataSource.OpenConnectionAsync();
    return (await conn.QueryAsync<TopCliente>(
        @"SELECT c.id, c.nome, SUM(p.valor) AS total, COUNT(*) AS qtde
          FROM pedidos p
          JOIN clientes c ON c.id = p.cliente_id
          WHERE p.criado_em >= @mes
          GROUP BY c.id, c.nome
          ORDER BY total DESC
          LIMIT 5",
        new { mes })).ToList();
}

// Teste anti-N+1:
[Fact]
public async Task Listar_NaoFazNplus1() {
    using var listener = SqlListener.Capture();
    await _service.ListarTodosOsPedidosComCliente();
    Assert.True(listener.QueryCount <= 2,
        $"esperava ≤2 queries, obteve {listener.QueryCount}");
}

Padrão estabelecido no ecossistema .NET: EF Core para a maior parte do CRUD; Dapper (ou EF.FromSqlRaw) para queries analíticas onde EF gera SQL ruim. Os dois convivem na mesma conexão.

Python — SQLAlchemy 2.0 ORM + Core
from sqlalchemy import select, func, text
from sqlalchemy.orm import selectinload

# ORM para CRUD
def buscar(session, pedido_id: int) -> Pedido | None:
    return session.scalar(
        select(Pedido)
        .options(selectinload(Pedido.itens))
        .where(Pedido.id == pedido_id))

# Core/text para query analítica
def top_clientes_do_mes(session, mes: date) -> list[TopCliente]:
    sql = text("""
        SELECT c.id, c.nome, SUM(p.valor) AS total, COUNT(*) AS qtde
        FROM pedidos p
        JOIN clientes c ON c.id = p.cliente_id
        WHERE p.criado_em >= :mes
        GROUP BY c.id, c.nome
        ORDER BY total DESC
        LIMIT 5
    """)
    rows = session.execute(sql, {"mes": mes}).mappings()
    return [TopCliente(**r) for r in rows]

# Teste anti-N+1 com pytest-sqlalchemy ou contadores manuais
def test_listar_nao_faz_nplus1(session, query_counter):
    service.listar_pedidos_com_cliente()
    assert query_counter.count <= 2

SQLAlchemy 2.0 unifica Core e ORM mais que versões anteriores — mesmo select() funciona em ambos. selectinload é frequentemente preferível a joinedload: duas queries simples ganham de uma query com produto cartesiano.

Go — sqlc puro (sem ORM)
-- queries.sql
-- name: BuscarPedidoPorID :one
SELECT * FROM pedidos WHERE id = $1;

-- name: ListarItensDePedidos :many
SELECT * FROM itens_pedido WHERE pedido_id = ANY($1::bigint[]);

-- name: TopClientesDoMes :many
SELECT c.id, c.nome, SUM(p.valor)::numeric AS total, COUNT(*) AS qtde
FROM pedidos p
JOIN clientes c ON c.id = p.cliente_id
WHERE p.criado_em >= $1
GROUP BY c.id, c.nome
ORDER BY total DESC
LIMIT 5;
// Uso:
pedido, err := q.BuscarPedidoPorID(ctx, 42)
if err != nil { /* handle */ }

// Para listar pedidos com itens — sem N+1, com batch:
pedidos, _ := q.ListarPedidosDoCliente(ctx, clienteID)
ids := make([]int64, len(pedidos))
for i, p := range pedidos { ids[i] = p.ID }
itens, _ := q.ListarItensDePedidos(ctx, ids)
// Agrupar em memória:
itensPorPedido := make(map[int64][]ItemPedido)
for _, it := range itens {
    itensPorPedido[it.PedidoID] = append(itensPorPedido[it.PedidoID], it)
}

Go favorece SQL como source. sqlc gera código tipado a partir do SQL e do schema; nada de mágica. N+1 é tratado explicitamente — você compõe queries em batch quando precisa, sem ORM decidindo por você.

Como decidir, no seu projeto

A decisão entre ORM/sqlc/SQL-puro depende de fatores contextuais:

Não há resposta correta universal. Há resposta correta para seu projeto, considerando todas essas dimensões. O erro é dogmatismo: nem "ORM é sempre melhor" nem "ORM é lixo". Sêniores experientes usam ambos com critério.

Como praticar

  1. Pegue uma query lenta gerada pelo ORM no seu sistema. Capture o SQL gerado, rode EXPLAIN ANALYZE, reescreva em SQL puro, rode de novo. Compare planos e tempos. Documente o ganho — e a razão de ORM ter gerado SQL pior.
  2. Implemente teste anti-N+1 para um endpoint crítico. Faça-o falhar primeiro (rode com lazy loading); conserte; mantenha o teste como sentinela contra regressão.
  3. Construa o catálogo do projeto do módulo com ORM puro; depois converta as 3 queries mais críticas para SQL puro com sqlc-like (Dapper em .NET, text em SQLAlchemy). Compare verbosity, performance, e complexidade cognitiva. Decida com evidência se o híbrido vale para esse projeto.

Referências para aprofundar

  1. livro Patterns of Enterprise Application Architecture — Martin Fowler (2002). Cap. sobre Object-Relational Mapping. Onde Repository, Unit of Work, Identity Map foram catalogados pela primeira vez. Ainda lê-se com proveito.
  2. livro Java Persistence with Hibernate — Christian Bauer et al. (2ª ed., 2015). Hibernate é avô de quase todo ORM moderno. Conceitos de session, lazy/eager, fetch strategies vieram daqui. Aplica-se a EF Core e SQLAlchemy também.
  3. livro Pro Entity Framework Core 8/9 — Adam Freeman (2024). Tratamento moderno e prático de EF Core, incluindo padrões para queries críticas e quando descer para SQL.
  4. livro Essential SQLAlchemy — Mike Bayer (2ª ed., 2015) + docs 2.0. Bayer é o autor da biblioteca; documentação dele é livro técnico. Para SQLAlchemy moderno, prefira a doc 2.0 (mais atual que o livro).
  5. artigo The Vietnam of Computer Science — Ted Neward (2006). odetocode.com/blogs/scott/archive/2006/01/16/13346.aspx — ensaio canônico sobre o impedance mismatch. Opinionado mas sólido.
  6. artigo ORM is an antipattern — Yegor Bugayenko. yegor256.com — provocação polêmica defendendo SQL puro com OOP fiel. Vale ler para calibrar.
  7. artigo The N+1 Problem — Vlad Mihalcea. vladmihalcea.com — Mihalcea é referência em performance Hibernate. Os princípios se aplicam a todo ORM.
  8. artigo SQLAlchemy 2.0 — what's changed — Mike Bayer. docs.sqlalchemy.org — release notes técnicas explicando o novo modelo unificado e desencorajando padrões antigos.
  9. artigo Why we use sqlc at Scale — Stripe / Khan Academy / outros. Vários casos publicados. Argumento canônico: tipagem sem ORM, com SQL como fonte da verdade.
  10. docs Entity Framework Core Performance Documentation. learn.microsoft.com/ef/core/performance — guia oficial de performance com seções específicas de N+1, projeções e quando descer para SQL.
  11. docs sqlc Documentation. docs.sqlc.dev — documentação curta e direta da ferramenta. Lê-se em uma hora; muda como você pensa em DB em Go.
  12. vídeo The Holy Grail of ORMs — Mike Bayer (PyCon). YouTube. Bayer mostra evolução das ideias de SQLAlchemy e onde ORMs erram. Material formativo.