Consultas dinâmicas seguras com Expression Trees e Specification no EF Core
Por John Pires · 09 de março de 2026

Consultas dinâmicas seguras com Expression Trees e Specification no EF Core
Motivação e trade-offs
Por que consultas dinâmicas?
Em aplicações reais você recebe filtros variáveis do cliente: combinações de busca por texto, intervalos numéricos, múltiplos selects, ordenação e paginação. Escrever métodos na camada de repositório para cada combinação explodiria em código duplicado. Consultas dinâmicas resolvem isso gerando a expressão de filtro em tempo de execução e aplicando-a ao IQueryable do EF Core.
Trade-offs importantes
Existem trade-offs entre flexibilidade, performance e segurança. Montar consultas com strings (SQL cru) dá flexibilidade máxima mas perde segurança e sklad de manutenção. LINQ + Expression Trees preservam tipagem, permitem compilação em SQL pelo EF Core e evitam injeção se feitos corretamente. Porém, expressões complexas podem não ser traduzíveis e causar avaliações no cliente (client evaluation), impactando performance e comportamento.
Dica: prefira construir Expression> combináveis, e mantenha a tradução para SQL em mente (evite métodos CLR não suportados pelo provider).
Fundamentos técnicos
Specification pattern e Expression Trees
Specification é um padrão que encapsula uma condição (predicado) em um objeto. Em C#, isso normalmente é representado por Expression>. Com expressões você pode compor lógica (AndAlso, OrElse), reusar filtros e ainda deixar o EF Core traduzir para SQL.
Composição segura de expressões
Ao combinar expressões é preciso re-mapear parâmetros para evitar múltiplos parâmetros distintos. Usar uma classe utilitária que substitui parâmetros permite compor sem que a expressão resultante cause erro de compilação ou tradução.
// Esta classe faz a substituição de parâmetros ao combinar duas expressões
public class ParameterRebinder : ExpressionVisitor
{
private readonly Dictionary<ParameterExpression, ParameterExpression> _map;
public ParameterRebinder(Dictionary<ParameterExpression, ParameterExpression> map)
{
_map = map ?? new Dictionary<ParameterExpression, ParameterExpression>();
}
protected override Expression VisitParameter(ParameterExpression node)
{
if (_map.TryGetValue(node, out var replacement))
return replacement;
return base.VisitParameter(node);
}
}
Após a substituição você pode construir uma expressão composta com Expression.AndAlso/OrElse. Isso gera um único Expression tree que o EF Core pode traduzir para SQL quando usado sobre IQueryable.
Exemplo 1: caso simples
Contexto: pesquisar produtos com nome e ativo
Objetivo: construir um filtro dinâmico que combine um filtro por nome (contains) e por status IsActive. Ambos podem ou não estar presentes nos parâmetros da API.
Implementação mínima da Spec e extensão
Primeiro mostramos uma Specification base e extensões para composição simples. Esse código é compacto e funciona em qualquer projeto .NET que use EF Core.
// Specification base e extensões de composição
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public Func<IQueryable<T>, IQueryable<T>> Apply => q => q.Where(ToExpression());
public Specification<T> And(Specification<T> other) => new AndSpecification<T>(this, other);
}
public class AndSpecification<T> : Specification<T>
{
private readonly Specification<T> _left;
private readonly Specification<T> _right;
public AndSpecification(Specification<T> left, Specification<T> right)
{
_left = left;
_right = right;
}
public override Expression<Func<T, bool>> ToExpression()
{
var leftExp = _left.ToExpression();
var rightExp = _right.ToExpression();
var param = Expression.Parameter(typeof(T));
var map = new Dictionary<ParameterExpression, ParameterExpression>
{
{ leftExp.Parameters[0], param },
{ rightExp.Parameters[0], param }
};
var leftBody = new ParameterRebinder(map).Visit(leftExp.Body);
var rightBody = new ParameterRebinder(map).Visit(rightExp.Body);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(leftBody, rightBody), param);
}
}
O código acima cria a infraestrutura mínima para combinar especificações. Note que usamos ParameterRebinder para unificar parâmetros e produzir uma única expressão.
Uso prático no repositório
Exemplo de uso com um DbContext e EF Core: aplicamos a Specification ao IQueryable antes de executar ToListAsync.
// Repositório usando Specification
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsActive { get; set; }
}
public class ActiveProductSpecification : Specification<Product>
{
public override Expression<Func<Product, bool>> ToExpression() => p => p.IsActive;
}
public class NameContainsSpecification : Specification<Product>
{
private readonly string _value;
public NameContainsSpecification(string value) => _value = value;
public override Expression<Func<Product, bool>> ToExpression() => p => p.Name.Contains(_value);
}
// Uso
var spec = new ActiveProductSpecification();
if (!string.IsNullOrEmpty(nameFilter))
spec = spec.And(new NameContainsSpecification(nameFilter));
var results = await dbContext.Set<Product>().Where(spec.ToExpression()).ToListAsync();
O que acontece: se nameFilter for nulo, aplicamos apenas o filtro de ativo. Caso contrário, combinamos com o filtro por nome. O EF Core traduzirá a expressão combinada para um WHERE com AND.
Exemplo 2: caso avançado do mundo real
Contexto: filtros múltiplos, includes, ordenação e paginação
Aplicação típica: endpoint que recebe criteria dinâmicas — arrays de IDs, intervalos de datas, busca por múltiplos campos, ordenação por campo informado e includes (navegações). Queremos manter a consulta traduzível para SQL e evitar N+1.
Construindo um QueryBuilder com Expression Trees
Vamos montar um builder que retorna um Func, IQueryable>. Isso torna fácil aplicar includes, filtros, ordenação e paginação em cadeia.
// QueryBuilder que agrega filtros, includes, ordenacao e paginação
public class QueryBuilder<T>
{
private readonly List<Expression<Func<T, bool>>> _filters = new();
private readonly List<string> _includes = new();
private string _orderBy;
private bool _orderDesc;
private int? _skip;
private int? _take;
public QueryBuilder<T> Where(Expression<Func<T, bool>> predicate)
{
_filters.Add(predicate);
return this;
}
public QueryBuilder<T> Include(string navigationPropertyPath)
{
_includes.Add(navigationPropertyPath);
return this;
}
public QueryBuilder<T> OrderBy(string prop, bool desc = false)
{
_orderBy = prop;
_orderDesc = desc;
return this;
}
public QueryBuilder<T> Page(int skip, int take)
{
_skip = skip; _take = take; return this;
}
public IQueryable<T> Apply(IQueryable<T> query)
{
foreach (var inc in _includes) query = query.Include(inc);
if (_filters.Any())
{
var combined = _filters.Aggregate((Expression<Func<T, bool>>?)null, (acc, next) => acc == null ? next : ComposeAnd(acc, next));
query = query.Where(combined!);
}
if (!string.IsNullOrEmpty(_orderBy))
{
query = ApplyOrder(query, _orderBy, _orderDesc);
}
if (_skip.HasValue) query = query.Skip(_skip.Value);
if (_take.HasValue) query = query.Take(_take.Value);
return query;
}
private static Expression<Func<T, bool>> ComposeAnd(Expression<Func<T, bool>> left, Expression<Func<T, bool>> right)
{
var param = Expression.Parameter(typeof(T));
var map = new Dictionary<ParameterExpression, ParameterExpression>
{
{ left.Parameters[0], param },
{ right.Parameters[0], param }
};
var leftBody = new ParameterRebinder(map).Visit(left.Body);
var rightBody = new ParameterRebinder(map).Visit(right.Body);
return Expression.Lambda<Func<T, bool>>(Expression.AndAlso(leftBody, rightBody), param);
}
private static IQueryable<T> ApplyOrder(IQueryable<T> source, string propName, bool desc)
{
// usa reflection para montar OrderBy/ThenBy dinamico — traduzível desde que prop seja simples
var param = Expression.Parameter(typeof(T));
var body = Expression.PropertyOrField(param, propName);
var keySelector = Expression.Lambda(body, param);
var method = desc ? "OrderByDescending" : "OrderBy";
var call = Expression.Call(typeof(Queryable), method, new[] { typeof(T), body.Type }, source.Expression, Expression.Quote(keySelector));
return source.Provider.CreateQuery<T>(call);
}
}
Contexto antes do código: esse QueryBuilder aceita includes por string (útil para cenários dinâmicos) e compõe filtros. Após aplicar Apply ao DbSet, você obtém IQueryable pronto para execução com ToListAsync.
Exemplo de uso real
Endpoint minimal API que recebe DTO de filtros e aplica o builder ao DbContext:
// DTO de filtros e uso em minimal API
public record ProductFilterDto(string? Name, int[]? CategoryIds, DateTime? CreatedFrom, DateTime? CreatedTo, string? OrderBy, bool Desc, int Page = 0, int PageSize = 20);
app.MapGet("/products", async (ProductFilterDto dto, AppDbContext db) =>
{
var builder = new QueryBuilder<Product>();
if (!string.IsNullOrEmpty(dto.Name)) builder.Where(p => p.Name.Contains(dto.Name));
if (dto.CategoryIds?.Any() == true) builder.Where(p => dto.CategoryIds.Contains(p.CategoryId));
if (dto.CreatedFrom.HasValue) builder.Where(p => p.CreatedAt >= dto.CreatedFrom.Value);
if (dto.CreatedTo.HasValue) builder.Where(p => p.CreatedAt <= dto.CreatedTo.Value);
builder.Include("Category");
builder.OrderBy(dto.OrderBy ?? "Id", dto.Desc);
builder.Page(dto.Page * dto.PageSize, dto.PageSize);
var query = builder.Apply(db.Products.AsQueryable());
var list = await query.ToListAsync();
return Results.Ok(list);
});
O que acontece: as condições são convertidas em um único SQL com WHERE composto, JOIN para include e ORDER BY dinâmico. Serviços como filtros por lista (Contains em array) são traduzíveis e eficientes quando usados corretamente.
Debugging e troubleshooting
Erros comuns e como identificá-los
Listo os problemas mais frequentes ao trabalhar com expressões dinâmicas no EF Core e como solucioná-los:
- Could not be translated: o EF Core não sabe converter um método CLR para SQL. Solução: mova a operação para o cliente (não recomendado) ou reescreva usando métodos suportados (e.g., usar string.Contains ou EF.Functions.Like).
- Client evaluation: EF Core 3+ lança exceção ou executa parcialmente no cliente. Verifique logs de tradução e evite fechar sobre contextos externos ou métodos não suportados.
- Parameter mismatch: quando você combina expressões sem parameter rebinding o provider pode falhar. Use a técnica do ParameterRebinder.
- Include ignorado: includes em queries que usam Select para anônimos são descartados. Aplique includes antes do Select ou projete com objetos que preservem navegações.
Ferramentas e práticas para debugar
Use log SQL do EF Core (EnableSensitiveDataLogging com cautela) para ver o SQL gerado. Outra técnica é materializar etapas intermediárias com ToQueryString() (EF Core 5+) para inspecionar o comando sem executar. Adicionalmente, escreva testes unitários para expressões: compile ToExpression e verifique o corpo.
Dica prática: quando receber "translation failed" tente isolar a sub-expressão: substitua pelo valor literal e veja se o erro some — isso ajuda a localizar a parte não traduzível.
Erros comuns, armadilhas e dicas práticas
Captura de variáveis (closures)
Fechos (closures) sobre variáveis mutáveis podem causar comportamento inesperado. Sempre capture valores imutáveis (readonly local) ou copie para variáveis locais antes de criar a expressão. Exemplo: dentro de um loop, copiar o item para uma variável local para usar na expressão evita bugs.
Uso de métodos não traduzíveis
Métodos customizados em expressões não serão traduzidos. Se precisar de lógica complexa, considere:
- Transformar para operações suportadas pelo provider
- Projeção para DTOs e aplicar lógica em memória apenas após paginação
- Usar computed columns / SQL functions e mapear com EF.Functions
Conclusão
Resumo prático
Expression Trees + Specification fornecem uma maneira tipada, testável e segura de construir consultas dinâmicas no EF Core. A chave é compor expressões corretamente (rebind de parâmetros), manter as expressões traduzíveis e aplicar includes/ordenacao/paginacao de forma previsível.
Próximos passos
Implemente um conjunto de specs reutilizáveis para sua camada de domínio, crie um QueryBuilder bem testado e adicione logging de ToQueryString() para monitorar o SQL. Priorize a tradução para SQL e evite avaliações no cliente em coleções grandes.
Este artigo forneceu código pronto para usar e padrões aplicáveis. Adapte as amostras ao seu modelo e ao seu DbContext, escreva testes cobrindo composição de expressões e monitore a perfomance das queries geradas.
Boas práticas imediatas: evitar reflection desnecessária em hot paths, preferir includes explícitos quando precisar de navegações e centralizar regras complexas de filtro em Specifications testáveis.
Assuntos: C#, ef-core, linq, specification
Leia também
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…
EF Core + Dapper: padrão híbrido (Read/Write) para consultas de alta performance
Padrão híbrido: use EF Core para writes e Dapper para reads. Mostro código real — repositórios, transações compartilhadas, queries parametrizadas e paginação…
Importação massiva de CSV com Npgsql COPY em Minimal APIs e EF Core
Como projetar importação massiva de CSV com Npgsql COPY em Minimal APIs + EF Core: estratégias, exemplos práticos, staging, upsert e debugging para pipelines…
Multitenancy em ASP.NET Core com EF Core: discriminator, schema e database
Comparação prática entre discriminator, schema e database para multitenancy em ASP.NET Core com EF Core. Implementação de TenantProvider, DbContext dinâmico…
Soft delete no EF Core: filtros globais, auditoria e restauração
Soft delete com EF Core evita perda de dados, permite auditoria e restauração. Aprenda o jeito errado (filtros manuais) e o jeito certo (global query filters…
Tratar concorrência otimista e retries com EF Core e Polly em ASP.NET Core
Combina rowversion, tratamento de DbUpdateConcurrencyException e políticas Polly para retries seguros. Estratégia prática: recriar DbContext, mesclar ou…