Como aplicar o padrão specification no domínio de negócio

1. Introdução ao Padrão Specification

O padrão Specification foi formalizado por Eric Evans em seu livro "Domain-Driven Design: Tackling Complexity in the Heart of Software" como uma forma de encapsular regras de negócio em objetos reutilizáveis. A ideia central é simples: em vez de espalhar lógica de validação e filtragem por repositórios, serviços e queries, você cria objetos especializados que representam uma condição de negócio.

O problema comum que o padrão resolve é a dispersão de regras. Em sistemas tradicionais, uma regra como "cliente pode receber desconto" aparece em múltiplos lugares: no serviço de pedidos, na query do banco, na validação de frontend. Qualquer alteração exige caça aos fragmentos.

Os benefícios são claros:
- Reuso: uma mesma Specification pode ser usada em validação, filtragem e autorização
- Testabilidade: cada regra é testada isoladamente
- Isolamento: a lógica de domínio fica protegida de detalhes de infraestrutura

2. Estrutura Básica de uma Specification

A estrutura fundamental consiste em uma interface genérica com um método IsSatisfiedBy e operadores de composição lógica.

// Interface base
interface Specification<T> {
    boolean IsSatisfiedBy(T entity);
    Specification<T> And(Specification<T> other);
    Specification<T> Or(Specification<T> other);
    Specification<T> Not();
}

// Implementação concreta
class ClienteAtivoSpecification implements Specification<Cliente> {
    boolean IsSatisfiedBy(Cliente cliente) {
        return cliente.getStatus() == Status.ATIVO 
            && cliente.getDataCancelamento() == null;
    }
}

// Composição
Specification<Cliente> clienteVipAtivo = 
    new ClienteAtivoSpecification()
        .And(new ClienteVipSpecification());

A composição com operadores permite construir regras complexas a partir de blocos simples, seguindo o princípio de responsabilidade única.

3. Mapeando Regras de Negócio em Specifications

Para identificar regras candidatas, observe três categorias principais:

  1. Validações: "pedido não pode exceder limite de crédito"
  2. Filtros: "mostrar apenas produtos em estoque"
  3. Autorizações: "usuário pode cancelar pedido apenas nas primeiras 24 horas"

Exemplo em um domínio de e-commerce:

// Regras de elegibilidade para desconto
class ElegibilidadeDescontoSpecification implements Specification<Pedido> {
    boolean IsSatisfiedBy(Pedido pedido) {
        return pedido.getValorTotal() > 100.0
            && pedido.getCliente().isPrimeiroPedido() == false
            && pedido.getDataPedido().getMonth() == Month.DECEMBER;
    }
}

// Separação entre regras mutáveis e fixas
class LimiteLegalSpecification implements Specification<Pedido> {
    boolean IsSatisfiedBy(Pedido pedido) {
        return pedido.getDescontoAplicado() <= 0.3 * pedido.getValorTotal();
    }
}

// Regra sazonal (mutável)
class PromocaoBlackFridaySpecification implements Specification<Pedido> {
    boolean IsSatisfiedBy(Pedido pedido) {
        return pedido.getDataPedido().isAfter(blackFridayStart)
            && pedido.getDataPedido().isBefore(blackFridayEnd);
    }
}

Regras fixas como limites legais raramente mudam, enquanto regras promocionais podem ser ativadas/desativadas sem tocar no código central.

4. Integração com Repositórios e ORMs

A integração com bancos de dados requer cuidado: avaliação in-memory vs. avaliação no banco. Para grandes volumes, a avaliação deve ocorrer no banco via LINQ, Criteria API ou Query Objects.

// Specification para consulta no banco (EF Core)
class PedidosVencidosSpecification implements Specification<Pedido> {
    Expression<Func<Pedido, bool>> ToExpression() {
        return pedido => pedido.getDataVencimento() < DateTime.Now 
            && pedido.getStatus() == Status.PENDENTE;
    }
}

// Repositório usando Specification para filtrar
class PedidoRepository {
    List<Pedido> FindBySpecification(Specification<Pedido> spec) {
        if (spec instanceof SqlSpecification) {
            var expression = ((SqlSpecification) spec).ToExpression();
            return dbContext.Pedidos.Where(expression).ToList();
        } else {
            return dbContext.Pedidos.ToList()
                .Where(p => spec.IsSatisfiedBy(p))
                .ToList();
        }
    }
}

// Uso prático
var vencidos = pedidoRepository.FindBySpecification(
    new PedidosVencidosSpecification()
);

A diferença é crucial: usar IsSatisfiedBy em memória para 100 mil registros pode ser inviável. A solução é ter Specifications que expõem expressões traduzíveis para SQL.

5. Testabilidade e Isolamento da Lógica

Cada Specification deve ser testada isoladamente, sem dependências de banco ou serviços.

// Teste unitário puro
@Test
void clienteAtivoSpecification_DeveRetornarTrue_QuandoClienteAtivo() {
    var cliente = new Cliente();
    cliente.setStatus(Status.ATIVO);
    cliente.setDataCancelamento(null);

    var spec = new ClienteAtivoSpecification();

    assertTrue(spec.IsSatisfiedBy(cliente));
}

// Teste de composição
@Test
void composicaoAnd_DeveExigirAmbasCondicoes() {
    var cliente = new Cliente();
    cliente.setStatus(Status.ATIVO);
    cliente.setTotalCompras(1500.0);

    var spec = new ClienteAtivoSpecification()
        .And(new ClienteVipSpecification(1000.0));

    assertTrue(spec.IsSatisfiedBy(cliente));
}

// Teste com data mockada
@Test
void promocaoSazonal_DeveConsiderarDataAtual() {
    var pedido = new Pedido();
    pedido.setDataPedido(LocalDate.of(2024, 12, 25));

    var spec = new PromocaoNatalSpecification(
        LocalDate.of(2024, 12, 20),
        LocalDate.of(2024, 12, 31)
    );

    assertTrue(spec.IsSatisfiedBy(pedido));
}

Cenários de borda como composições aninhadas (A AND (B OR C)) devem ser testados explicitamente para garantir a precedência lógica correta.

6. Negociação com Stakeholders Usando Specifications

Specifications funcionam como "blocos de construção" visíveis para o negócio. Você pode mostrar ao stakeholder uma lista de Specifications e perguntar: "Quais regras devem valer agora?"

// Antes: regra de frete grátis hardcoded no serviço
class PedidoService {
    void calcularFrete(Pedido pedido) {
        if (pedido.getValorTotal() > 200 
            && pedido.getCliente().isPrimeiraCompra()
            && pedido.getDataPedido().getDayOfWeek() == DayOfWeek.FRIDAY) {
            pedido.setFrete(0.0);
        }
    }
}

// Depois: regra composta por Specifications independentes
class PedidoService {
    void calcularFrete(Pedido pedido, Specification<Pedido> regraFreteGratis) {
        if (regraFreteGratis.IsSatisfiedBy(pedido)) {
            pedido.setFrete(0.0);
        }
    }
}

// O stakeholder pode mudar a regra sem tocar no serviço
var novaRegra = new ValorMinimoSpecification(150.0)
    .And(new ClienteRecorrenteSpecification())
    .Or(new PromocaoSextaFeiraSpecification());

Mudar a regra de frete grátis de "acima de R$200" para "acima de R$150" vira apenas alteração em uma Specification, sem reescrever lógica de pedido.

7. Armadilhas e Boas Práticas

Armadilha 1: Specification genérica demais
Evite criar uma Specification<T> que aceita expressões lambda arbitrárias. Isso vira um "god object" que perde o significado de negócio.

// Ruim: perde o significado
class GenericSpecification<T> implements Specification<T> {
    private Predicate<T> predicate;
    // ... sem contexto de negócio
}

// Bom: cada Specification tem nome e propósito claros
class ClienteComAltoRiscoSpecification implements Specification<Cliente> {
    // regra específica: cliente com mais de 3 atrasos
}

Armadilha 2: Performance em avaliação em memória
Sempre avalie se a Specification será usada para filtrar milhares de registros. Nesse caso, implemente uma versão que gera expressões SQL.

Armadilha 3: Versionamento de regras
Regras de negócio mudam. Considere versionar Specifications para rastrear mudanças:

// Specification versionada
class LimiteCreditoSpecificationV2 implements Specification<Cliente> {
    // Versão 2: aumentou limite de 5000 para 10000
    boolean IsSatisfiedBy(Cliente cliente) {
        return cliente.getLimiteDisponivel() >= 10000;
    }
}

Boas práticas:
- Nomeie Specifications com linguagem ubíqua do negócio
- Mantenha Specifications pequenas (máximo 3-5 condições)
- Prefira composição a herança
- Documente o motivo de negócio de cada Specification

Referências