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.