.NET Design Patterns Entity Framework Core DDD Arquitetura Boas Práticas

Specification Pattern no .NET: consultas reutilizáveis, combináveis e testáveis

Implemente o Specification Pattern no .NET para encapsular regras de negócio em consultas reutilizáveis: ISpecification genérica.

N
Neryx Digital Architects
15 de fevereiro de 2026
12 min de leitura
190 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Consultas espalhadas por toda a codebase — Where(o => o.Status == "active" && o.CreatedAt > DateTime.UtcNow.AddDays(-30)) duplicado em repositórios, services e até controllers — são um sinal claro de que regras de negócio estão vazando para a infraestrutura. O Specification Pattern resolve isso: encapsula critérios de negócio em objetos reutilizáveis, combináveis e testáveis independentemente de banco de dados.

A interface base

// src/Domain/Specifications/ISpecification.cs
public interface ISpecification<T>
{
    // Para EF Core: convertida em SQL via expression tree
    Expression<Func<T, bool>> ToExpression();

    // Para avaliação em memória (testes, listas em memória)
    bool IsSatisfiedBy(T entity);
}

// Classe base que implementa IsSatisfiedBy automaticamente
public abstract class Specification<T> : ISpecification<T>
{
    public abstract Expression<Func<T, bool>> ToExpression();

    // Compila a expressão uma vez e usa em memória
    public bool IsSatisfiedBy(T entity)
        => ToExpression().Compile()(entity);

    // Operadores de composição
    public Specification<T> And(Specification<T> other)
        => new AndSpecification<T>(this, other);

    public Specification<T> Or(Specification<T> other)
        => new OrSpecification<T>(this, other);

    public Specification<T> Not()
        => new NotSpecification<T>(this);
}

Composição: And, Or, Not

// Especificações compostas — combina expression trees para gerar SQL eficiente

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 leftExpr = _left.ToExpression();
        var rightExpr = _right.ToExpression();

        // Substitui o parâmetro da expressão direita pelo da esquerda
        // para que ambas usem o mesmo parâmetro ao serem combinadas
        var parameter = leftExpr.Parameters[0];
        var rightBody = ExpressionParameterReplacer.Replace(
            rightExpr.Body, rightExpr.Parameters[0], parameter);

        return Expression.Lambda<Func<T, bool>>(
            Expression.AndAlso(leftExpr.Body, rightBody),
            parameter);
    }
}

public class OrSpecification<T> : Specification<T>
{
    private readonly Specification<T> _left;
    private readonly Specification<T> _right;

    public OrSpecification(Specification<T> left, Specification<T> right)
    {
        _left = left;
        _right = right;
    }

    public override Expression<Func<T, bool>> ToExpression()
    {
        var leftExpr = _left.ToExpression();
        var rightExpr = _right.ToExpression();

        var parameter = leftExpr.Parameters[0];
        var rightBody = ExpressionParameterReplacer.Replace(
            rightExpr.Body, rightExpr.Parameters[0], parameter);

        return Expression.Lambda<Func<T, bool>>(
            Expression.OrElse(leftExpr.Body, rightBody),
            parameter);
    }
}

public class NotSpecification<T> : Specification<T>
{
    private readonly Specification<T> _inner;

    public NotSpecification(Specification<T> inner)
        => _inner = inner;

    public override Expression<Func<T, bool>> ToExpression()
    {
        var innerExpr = _inner.ToExpression();
        return Expression.Lambda<Func<T, bool>>(
            Expression.Not(innerExpr.Body),
            innerExpr.Parameters[0]);
    }
}

// Helper para substituir parâmetros em expression trees
internal class ExpressionParameterReplacer : ExpressionVisitor
{
    private readonly ParameterExpression _from;
    private readonly Expression _to;

    private ExpressionParameterReplacer(ParameterExpression from, Expression to)
    {
        _from = from;
        _to = to;
    }

    public static Expression Replace(Expression expr, ParameterExpression from, Expression to)
        => new ExpressionParameterReplacer(from, to).Visit(expr)!;

    protected override Expression VisitParameter(ParameterExpression node)
        => node == _from ? _to : base.VisitParameter(node);
}

Especificações de negócio concretas

// src/Domain/Specifications/OrderSpecifications.cs

// Pedidos de um cliente específico
public class OrdersByCustomerSpec : Specification<Order>
{
    private readonly Guid _customerId;

    public OrdersByCustomerSpec(Guid customerId)
        => _customerId = customerId;

    public override Expression<Func<Order, bool>> ToExpression()
        => order => order.CustomerId == _customerId;
}

// Pedidos com status específico
public class OrdersByStatusSpec : Specification<Order>
{
    private readonly OrderStatus _status;

    public OrdersByStatusSpec(OrderStatus status)
        => _status = status;

    public override Expression<Func<Order, bool>> ToExpression()
        => order => order.Status == _status;
}

// Pedidos recentes (últimos N dias)
public class RecentOrdersSpec : Specification<Order>
{
    private readonly int _days;

    public RecentOrdersSpec(int days = 30)
        => _days = days;

    public override Expression<Func<Order, bool>> ToExpression()
    {
        var cutoff = DateTime.UtcNow.AddDays(-_days);
        return order => order.CreatedAt >= cutoff;
    }
}

// Pedidos de alto valor
public class HighValueOrdersSpec : Specification<Order>
{
    private readonly decimal _minimumValue;

    public HighValueOrdersSpec(decimal minimumValue = 1000m)
        => _minimumValue = minimumValue;

    public override Expression<Func<Order, bool>> ToExpression()
        => order => order.Total >= _minimumValue;
}

// Pedidos elegíveis para desconto de fidelidade
// Composição de regras de negócio complexas em um único objeto
public class EligibleForLoyaltyDiscountSpec : Specification<Order>
{
    public override Expression<Func<Order, bool>> ToExpression()
        => order =>
            order.Customer.TotalOrders >= 10 &&
            order.Customer.TotalSpent >= 5000m &&
            !order.HasDiscount &&
            order.Status == OrderStatus.Pending;
}

Integração com EF Core

// src/Infrastructure/Repositories/SpecificationRepository.cs
// Repositório genérico que aceita specifications

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context)
        => _context = context;

    // Retorna a query base — composição de specifications externamente
    public IQueryable<Order> Query()
        => _context.Orders.AsNoTracking();

    // Método que aceita specification — o EF Core converte para SQL
    public async Task<IReadOnlyList<Order>> FindAsync(
        Specification<Order> spec,
        CancellationToken ct = default)
    {
        return await _context.Orders
            .AsNoTracking()
            .Where(spec.ToExpression())  // ← expression tree vira SQL
            .ToListAsync(ct);
    }

    public async Task<Order?> FindOneAsync(
        Specification<Order> spec,
        CancellationToken ct = default)
    {
        return await _context.Orders
            .AsNoTracking()
            .FirstOrDefaultAsync(spec.ToExpression(), ct);
    }

    public async Task<int> CountAsync(
        Specification<Order> spec,
        CancellationToken ct = default)
    {
        return await _context.Orders
            .CountAsync(spec.ToExpression(), ct);
    }

    public async Task<bool> AnyAsync(
        Specification<Order> spec,
        CancellationToken ct = default)
    {
        return await _context.Orders
            .AnyAsync(spec.ToExpression(), ct);
    }
}

Composição de specifications nos casos de uso

// src/Application/UseCases/GetCustomerOrdersUseCase.cs

public class GetCustomerOrdersUseCase
{
    private readonly IOrderRepository _repository;

    public GetCustomerOrdersUseCase(IOrderRepository repository)
        => _repository = repository;

    public async Task<IReadOnlyList<Order>> ExecuteAsync(
        Guid customerId,
        OrderStatus? status = null,
        bool onlyRecent = false,
        CancellationToken ct = default)
    {
        // Composição fluente de specifications
        Specification<Order> spec = new OrdersByCustomerSpec(customerId);

        if (status.HasValue)
            spec = spec.And(new OrdersByStatusSpec(status.Value));

        if (onlyRecent)
            spec = spec.And(new RecentOrdersSpec(days: 90));

        return await _repository.FindAsync(spec, ct);
    }
}

// Uso mais complexo: pedidos elegíveis para desconto
public class ApplyLoyaltyDiscountUseCase
{
    private readonly IOrderRepository _repository;

    public ApplyLoyaltyDiscountUseCase(IOrderRepository repository)
        => _repository = repository;

    public async Task ExecuteAsync(Guid customerId, CancellationToken ct)
    {
        // Combina especificação de cliente com elegibilidade para desconto
        var spec = new OrdersByCustomerSpec(customerId)
            .And(new EligibleForLoyaltyDiscountSpec());

        var eligibleOrders = await _repository.FindAsync(spec, ct);

        foreach (var order in eligibleOrders)
            order.ApplyLoyaltyDiscount(percentage: 0.10m);
    }
}

// Relatório de pedidos de alto valor recentes
public class GetHighValueRecentOrdersUseCase
{
    private readonly IOrderRepository _repository;

    public GetHighValueRecentOrdersUseCase(IOrderRepository repository)
        => _repository = repository;

    public async Task<IReadOnlyList<Order>> ExecuteAsync(CancellationToken ct)
    {
        var spec = new HighValueOrdersSpec(minimumValue: 2000m)
            .And(new RecentOrdersSpec(days: 7))
            .And(new OrdersByStatusSpec(OrderStatus.Completed));

        return await _repository.FindAsync(spec, ct);
    }
}

Specification com ordenação e paginação

// Versão estendida com ordenação, includes e paginação
public interface ISpecification<T>
{
    Expression<Func<T, bool>> ToExpression();
    bool IsSatisfiedBy(T entity);

    // Extensões para consultas mais ricas
    Expression<Func<T, object>>? OrderBy { get; }
    Expression<Func<T, object>>? OrderByDescending { get; }
    List<Expression<Func<T, object>>> Includes { get; }
    int? Take { get; }
    int? Skip { get; }
}

// Specification com paginação para listagens
public class PagedOrdersSpec : Specification<Order>
{
    private readonly OrderStatus? _status;
    private readonly int _page;
    private readonly int _pageSize;

    public PagedOrdersSpec(int page, int pageSize, OrderStatus? status = null)
    {
        _status = status;
        _page = page;
        _pageSize = pageSize;
        Skip = (page - 1) * pageSize;
        Take = pageSize;
        OrderByDescending = order => order.CreatedAt;
    }

    public Expression<Func<Order, object>>? OrderByDescending { get; }
    public int? Skip { get; }
    public int? Take { get; }

    public override Expression<Func<Order, bool>> ToExpression()
        => _status.HasValue
            ? order => order.Status == _status.Value
            : order => true;
}

// Repositório aplicando paginação quando disponível
public async Task<(IReadOnlyList<Order> Items, int Total)> FindPagedAsync(
    PagedOrdersSpec spec,
    CancellationToken ct = default)
{
    var query = _context.Orders.Where(spec.ToExpression());

    var total = await query.CountAsync(ct);

    if (spec.OrderByDescending is not null)
        query = query.OrderByDescending(spec.OrderByDescending);

    if (spec.Skip.HasValue) query = query.Skip(spec.Skip.Value);
    if (spec.Take.HasValue) query = query.Take(spec.Take.Value);

    var items = await query.AsNoTracking().ToListAsync(ct);
    return (items, total);
}

Testes sem banco de dados

// Testes unitários — avaliação em memória com IsSatisfiedBy
// Não precisam de banco de dados, migrations ou mocks de DbContext

public class OrderSpecificationTests
{
    [Fact]
    public void HighValueOrdersSpec_OrderAboveThreshold_IsSatisfied()
    {
        var order = OrderBuilder.New()
            .WithTotal(1500m)
            .Build();

        var spec = new HighValueOrdersSpec(minimumValue: 1000m);

        spec.IsSatisfiedBy(order).Should().BeTrue();
    }

    [Fact]
    public void HighValueOrdersSpec_OrderBelowThreshold_IsNotSatisfied()
    {
        var order = OrderBuilder.New()
            .WithTotal(500m)
            .Build();

        var spec = new HighValueOrdersSpec(minimumValue: 1000m);

        spec.IsSatisfiedBy(order).Should().BeFalse();
    }

    [Fact]
    public void ComposedSpec_AndComposition_BothMustBeSatisfied()
    {
        var recentHighValueOrder = OrderBuilder.New()
            .WithTotal(2000m)
            .WithCreatedAt(DateTime.UtcNow.AddDays(-5))
            .Build();

        var oldHighValueOrder = OrderBuilder.New()
            .WithTotal(2000m)
            .WithCreatedAt(DateTime.UtcNow.AddDays(-60))
            .Build();

        var spec = new HighValueOrdersSpec(1000m)
            .And(new RecentOrdersSpec(days: 30));

        spec.IsSatisfiedBy(recentHighValueOrder).Should().BeTrue();
        spec.IsSatisfiedBy(oldHighValueOrder).Should().BeFalse();
    }

    [Theory]
    [InlineData(OrderStatus.Pending, true)]
    [InlineData(OrderStatus.Completed, false)]
    [InlineData(OrderStatus.Cancelled, false)]
    public void EligibleForLoyaltyDiscountSpec_OnlyPendingEligible(
        OrderStatus status, bool expected)
    {
        var order = OrderBuilder.New()
            .WithStatus(status)
            .WithCustomerStats(totalOrders: 15, totalSpent: 6000m)
            .WithoutDiscount()
            .Build();

        var spec = new EligibleForLoyaltyDiscountSpec();

        spec.IsSatisfiedBy(order).Should().Be(expected);
    }

    [Fact]
    public void NotSpec_InvertsResult()
    {
        var cancelledOrder = OrderBuilder.New()
            .WithStatus(OrderStatus.Cancelled)
            .Build();

        var notCancelledSpec = new OrdersByStatusSpec(OrderStatus.Cancelled).Not();

        notCancelledSpec.IsSatisfiedBy(cancelledOrder).Should().BeFalse();
    }
}

// Builder para testes — facilita criação de objetos Order
public class OrderBuilder
{
    private decimal _total = 100m;
    private OrderStatus _status = OrderStatus.Pending;
    private DateTime _createdAt = DateTime.UtcNow;
    private Guid _customerId = Guid.NewGuid();
    private bool _hasDiscount = false;
    private int _customerTotalOrders = 5;
    private decimal _customerTotalSpent = 1000m;

    public static OrderBuilder New() => new();

    public OrderBuilder WithTotal(decimal total) { _total = total; return this; }
    public OrderBuilder WithStatus(OrderStatus status) { _status = status; return this; }
    public OrderBuilder WithCreatedAt(DateTime date) { _createdAt = date; return this; }
    public OrderBuilder WithCustomerId(Guid id) { _customerId = id; return this; }
    public OrderBuilder WithoutDiscount() { _hasDiscount = false; return this; }
    public OrderBuilder WithCustomerStats(int totalOrders, decimal totalSpent)
    {
        _customerTotalOrders = totalOrders;
        _customerTotalSpent = totalSpent;
        return this;
    }

    public Order Build() => new()
    {
        Id = Guid.NewGuid(),
        CustomerId = _customerId,
        Total = _total,
        Status = _status,
        CreatedAt = _createdAt,
        HasDiscount = _hasDiscount,
        Customer = new Customer
        {
            TotalOrders = _customerTotalOrders,
            TotalSpent = _customerTotalSpent
        }
    };
}

Quando usar (e quando não usar)

Cenário Usar Specification? Motivo
Regra de negócio reutilizada em múltiplos lugares ✅ Sim Evita duplicação, centraliza a lógica
Consulta complexa usada em apenas um lugar ⚠️ Avaliar Pode ser over-engineering; inline é mais simples
Critério que precisa ser testado isoladamente ✅ Sim IsSatisfiedBy() permite testes sem banco
Filtragem de listas em memória (domínio) ✅ Sim Mesma specification funciona em memoria e no banco
Queries de relatório com CTEs/window functions ❌ Não Use Dapper com SQL explícito — mais expressivo
Filtros dinâmicos de buscas (múltiplos parâmetros opcionais) ✅ Sim Composição dinâmica de specs é mais limpa que IQueryable inline

O Specification Pattern é mais valioso quando as regras de negócio se tornam complexas o suficiente para merecer um nome — e quando a mesma regra precisa funcionar tanto numa consulta ao banco quanto numa validação em memória. Para queries ad-hoc simples, um Where() inline ainda é a escolha mais direta.


Patterns como Specification são parte de uma arquitetura limpa e sustentável. Se você quer aplicar DDD e Clean Architecture de forma pragmática no seu time .NET, a Neryx pode ajudar.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.