Destino Dev

Blog

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:

  1. Transformar para operações suportadas pelo provider
  2. Projeção para DTOs e aplicar lógica em memória apenas após paginação
  3. 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