Em sistemas de software reais, especialmente em domínios ricos como faturamento, crédito, regras fiscais, validações regulatórias ou autorização, é comum que as regras de negócio cresçam em complexidade ao longo do tempo. O que começa como simples validações condicionais rapidamente se transforma em blocos extensos de if/else, difíceis de ler, testar e manter.
Esse cenário gera código rígido, de baixa coesão e fortemente acoplado às regras atuais do negócio. Alterações pequenas passam a exigir modificações em várias partes do sistema, aumentando o risco de regressões. Cada nova adição ou modificação de regra no sistema é feita com tanto medo que as regras antigas passam a ser intocáveis, e mais if/else são adicionados para lidar exclusivamente com regra nova. Para lidar com esse problema de forma elegante e sustentável, o Padrão Specification surge como uma solução poderosa.
No entanto, como mostrarei, é mais valioso entender onde e quando aplicar o Specification do que propriamente saber implementar. A implementação em si é relativamente simples, pode ser encapsulada em uma pequena biblioteca interna ou até substituída por soluções já existentes no ecossistema da stack utilizada. O que realmente faz diferença é a sensibilidade arquitetural para reconhecer os contextos em que o domínio está ficando denso, cheio de combinações de regras, com alto risco de divergência e dificuldade de teste. Em outras palavras: Specification não deveria ser visto como um truque de código elegante, mas como uma ferramenta tática para domar complexidade de negócio. Saber identificar os pontos onde if/else resolveo problema em vez de um conjunto de Specifications vai salvar o futuro do projeto e é uma habilidade muito mais crítica. Essa decisão raramente é puramente técnica, ela passa por entendimento de domínio, horizonte de evolução do sistema e maturidade do time.
Problema
Considere um objeto que precisa ser validado com base em múltiplas regras, por exemplo:
- algumas validações dependem do valor de outros campos
- regras podem variar conforme o contexto (tipo de operação, perfil do usuário, estado do objeto)
- novas regras são frequentemente adicionadas
- as regras precisam ser reutilizadas em diferentes fluxos
Uma abordagem ingênua e direta costuma resultar em algo como:
if (pedido.tipo == "EXPORTACAO") {
if (pedido.valor <= 0) erro()
if (pedido.cliente.pais == null) erro()
} else {
if (pedido.valor < 100) erro()
}
if (pedido.urgente && !pedido.aprovadorPresente()) erro()
Esse tipo de código apresenta diversos problemas:
- baixa legibilidade
- dificuldade de reutilização
- testes frágeis
- violação do Princípio Aberto-Fechado (OCP)
À medida que novas regras surgem, o código se deteriora rapidamente.
Padrão Specification
Uma implementação em Kotlin
Uma implementação típica em linguagem Kotlin pode ser algo como mostado a serguir.
interface Specification<ValueType> {
fun isSatisfiedBy(value: ValueType): Boolean
fun and(other: Specification<ValueType>): Specification<ValueType>
fun or(other: Specification<ValueType>): Specification<ValueType>
fun not(): Specification<ValueType>
}
A classe AbstractSpecification implementa todos os métodos de composição sobre as especificações. O único método não implementado é isSatisfiedBy que deve ser implementado por classes com regras de negócio específicas para um domínio.
abstract class AbstractSpecification<ValueType> : Specification<ValueType> {
override fun and(other: Specification<ValueType>): Specification<ValueType> {
return AndSpecification(right = other)
}
override fun or(other: Specification<ValueType>): Specification<ValueType> {
return OrSpecification(right = other)
}
override fun not(): Specification<ValueType> {
return NotSpecification()
}
private inner class AndSpecification(
private val right: Specification<ValueType>,
) : AbstractSpecification<ValueType>() {
override fun isSatisfiedBy(value: ValueType): Boolean {
return this@AbstractSpecification.isSatisfiedBy(value = value) and right.isSatisfiedBy(value = value)
}
}
private inner class OrSpecification(
private val right: Specification<ValueType>,
) : AbstractSpecification<ValueType>() {
override fun isSatisfiedBy(value: ValueType): Boolean {
return this@AbstractSpecification.isSatisfiedBy(value = value) or right.isSatisfiedBy(value = value)
}
}
private inner class NotSpecification() : AbstractSpecification<ValueType>() {
override fun isSatisfiedBy(value: ValueType): Boolean {
return this@AbstractSpecification.isSatisfiedBy(value = value).not()
}
}
}
Além dessas classes básicas, faz sentido criarmos uma para compor um conjunto maior de especificações.
class IterableOfArray<out T>(
private val array: Array<T>
) : Iterable<T> {
override fun iterator(): Iterator<T> = array.iterator()
}
object Specifications {
fun <T> any(specifications: Iterable<Specification<T>>): Specification<T> {
return object : AbstractSpecification<T>() {
override fun isSatisfiedBy(value: T): Boolean {
return specifications.any { it.isSatisfiedBy(value = value) }
}
}
}
fun <T> any(vararg specifications: Specification<T>): Specification<T> {
return any(specifications = IterableOfArray(array = specifications))
}
fun <T> all(specifications: Iterable<Specification<T>>): Specification<T> {
return object : AbstractSpecification<T>() {
override fun isSatisfiedBy(value: T): Boolean {
return specifications.all { it.isSatisfiedBy(value = value) }
}
}
}
fun <T> all(vararg specifications: Specification<T>): Specification<T> {
return all(specifications = IterableOfArray(array = specifications))
}
}
object Specifications {
fun <T> any(specifications: Iterable<Specification<T>>): Specification<T> {
return object : AbstractSpecification<T>() {
override fun isSatisfiedBy(value: T): Boolean {
return specifications.any { it.isSatisfiedBy(value = value) }
}
}
}
fun <T> any(vararg specifications: Specification<T>): Specification<T> {
return any(specifications = IterableOfArray(array = specifications))
}
fun <T> all(specifications: Iterable<Specification<T>>): Specification<T> {
return object : AbstractSpecification<T>() {
override fun isSatisfiedBy(value: T): Boolean {
return specifications.all { it.isSatisfiedBy(value = value) }
}
}
}
fun <T> all(vararg specifications: Specification<T>): Specification<T> {
return all(specifications = IterableOfArray(array = specifications))
}
}
Exemplo de uso
data class Order(
val totalAmount: Double,
val urgent: Boolean,
val vipCustomer: Boolean,
val international: Boolean
)
As implementações abaixo criam regras atómicas (e consequentemente fáceis de testar). Com essas regras, o padrão especificação permite criar composição delas.
class MinimumAmountSpecification(
private val minimum: Double
) : AbstractSpecification<Order>() {
override fun isSatisfiedBy(value: Order): Boolean {
return value.totalAmount >= minimum
}
}
class UrgentOrderSpecification : AbstractSpecification<Order>() {
override fun isSatisfiedBy(value: Order): Boolean {
return value.urgent
}
}
class VipCustomerSpecification : AbstractSpecification<Order>() {
override fun isSatisfiedBy(value: Order): Boolean {
return value.vipCustomer
}
}
class InternationalOrderSpecification : AbstractSpecification<Order>() {
override fun isSatisfiedBy(value: Order): Boolean {
return value.international
}
}
Uma composição de regras é mostrada a seguir.
val autoApprovalSpecification =
MinimumAmountSpecification(1000.0)
.and(InternationalOrderSpecification().not())
.or(VipCustomerSpecification())
Uma vez que especificação foi criada, ela pode serutilizada passando uma instância de objeto a ser validado.
val order = Order(
totalAmount = 1200.0,
urgent = false,
vipCustomer = false,
international = false
)
val approved = autoApprovalSpecification.isSatisfiedBy(order)
println("Order automatically approved? $approved")
O uso de Specifications.all permite englobar uma quantidade maior de regras, onde todas devem ser atendidas.
val expressShippingSpecification =
Specifications.all(
MinimumAmountSpecification(500.0),
UrgentOrderSpecification(),
InternationalOrderSpecification().not()
)
val order = Order(
totalAmount = 800.0,
urgent = true,
vipCustomer = false,
international = false
)
if (expressShippingSpecification.isSatisfiedBy(order)) {
println("Order eligible for express shipping")
}
Semelhantemente, o Specifications.any trabalhar com um conjunto de regras onde pelo menos uma delas deve ser atenidda.
val priorityOrderSpecification =
Specifications.any(
UrgentOrderSpecification(),
VipCustomerSpecification()
)
val order = Order(
totalAmount = 200.0,
urgent = false,
vipCustomer = true,
international = true
)
println(
"Priority order? ${
priorityOrderSpecification.isSatisfiedBy(order)
}"
)
Pontos a considerar
Num primeiro olhar, é muito comum olharmos para um cenário como o apresentado e pensar que o custo não vale a pena. Mas a verdade é que o padrão Specification não é a solução para tudo. Ele deve ser introduzido onde realmente pode ajudar, onde as regras são verdadeiramente complexas. Caso contrário, um simples if/else de três ou quatro linhas pode virar quase uma dezena de classes.
Aumento de complexidade acidental
Antes, nós tínhamos:
if (order.totalAmount >= 1000 && !order.international) { ... }
Depois passa a ter:
val autoApprovalSpec =
MinimumAmountSpecification(1000.0)
.and(InternationalOrderSpecification().not())
if (autoApprovalSpec.isSatisfiedBy(order)) { ... }
Para alguém novo no código, entender a segunda versão exige:
- saber o que é
Specification - entender que
and,or,notnão são operadores nativos, mas lógica encapsulada - descobrir onde estão as classes de cada regra
Ou seja, nós trocamos complexidade visível e direta por complexidade estrutural, isto é, mais classes, mais arquivos, mais indireção. Se as regras forem simples e poucas, isso é overengineering.
Explosão de classes
Num domínio grande, podemos chegar em classes como:
MinimumAmountSpecificationMaximumAmountSpecificationVipCustomerSpecificationNewCustomerSpecificationInternationalOrderSpecificationHighRiskCountrySpecification- etc.
Isso é ótimo do ponto de vista de coesão, mas os pacotes de domínio ficam cheios de classes bem pequenas e com o tempo, navegar entre as regras pode ficar chato, além estarmos aumentando a curva de entrada para novos devs no sistema.
Essa indireção dificulta tarefas como debugar. Num cenário mais direto, com if/else, basta colocar um break point e executar o código, quando o chegar lá, teremos a maioria das informações que precisamos para entender um lógica. Por outro lado, com uso massivo do padrão Specification, manter esse tipo de tarefa pode se tornar um pouco desafiador. Mas a necessidade de escovar bits tende a diminuir por conta do código desacoplado e bem coeso, isto é, naturalmente a qualidade do código tende a aumentar.
Overhead de performance
Do ponto de vista de execução, o overhead introduzido pelo uso do padrão Specification costuma ser mínimo. Na prática, ele se resume a chamadas de métodos simples e à criação de alguns objetos leves, algo irrelevante para a grande maioria das aplicações corporativas.
O custo mais significativo não é computacional, mas cognitivo. Existe um esforço maior para compreender abstrações, navegação entre classes e composição de regras. A performance só começa a ser uma preocupação real quando Specifications são aplicadas indiscriminadamente em loops massivos ou em trechos de código de altíssimo throughput, o que raramente coincide com validações de domínio, onde o padrão costuma ser empregado.
O uso de Specification passa a fazer muito sentido em domínios que exigem muitas combinações de regras. Em contextos onde uma decisão depende, por exemplo, simultaneamente do tipo de operação, do perfil do usuário, do canal de entrada e do estado do negócio, a lógica tende a crescer rapidamente em complexidade. Nesse cenário, o Specification se destaca porque permite reutilizar regras atômicas, compor decisões de forma declarativa e testar cada regra individualmente. O que antes era um emaranhado de condicionais passa a ser uma expressão clara do domínio.
Regras que mudam com frequência
Outro caso clássico é quando as regras de negócio sofrem mudanças constantes. Isso acontece, por exemplo, em legislações tributárias, políticas de crédito, critérios de aprovação de pedidos e normas de compliance. Quando as regras estão espalhadas em blocos condicionais, cada alteração se torna arriscada, pois pode impactar diversos fluxos sem que isso fique evidente.
Ao isolar cada regra em uma Specification e construir composições explícitas usando and, or, not, ou utilitários como Specifications.all e Specifications.any, fica muito mais simples localizar o ponto exato que precisa ser ajustado. O restante do comportamento permanece intacto, e novos testes podem ser adicionados sem quebrar os existentes. Com o tempo, forma-se uma espécie de catálogo de regras do domínio.
Múltiplos contextos de validação
Em domínios mais maduros, um mesmo objeto costuma ser avaliado sob diferentes perspectivas. Um pedido, por exemplo, pode precisar ser validado para aprovação financeira, para envio logístico e para faturamento. Cada um desses contextos possui regras próprias, mas compartilha vários critérios em comum.
Com Specification, cada contexto pode montar sua própria combinação de regras a partir das mesmas peças básicas. Isso evita duplicação de lógica e reduz drasticamente a quantidade de if/else espalhados por diferentes serviços, tornando os fluxos mais claros e consistentes.
Testabilidade e clareza de intenção
Do ponto de vista de testes, a diferença é significativa. Sem Specification, os testes tendem a se concentrar em métodos grandes e complexos, cobrindo dezenas de cenários ao mesmo tempo. Quando algo falha, identificar qual regra causou o problema pode ser difícil.
Com Specification, cada regra pode ser testada isoladamente, assim como suas composições. Fica explícito o que está sendo validado em cada teste, e a intenção da regra se torna evidente. Esse nível de clareza é especialmente valioso em sistemas de negócio grandes, onde regras mal compreendidas costumam gerar bugs caros.
Quando não usar
Apesar das vantagens, Specification não é indicada para todos os cenários. Em serviços CRUD simples, com regras poucas, estáveis e triviais, o padrão tende a gerar mais complexidade do que benefício. O mesmo vale para projetos pequenos ou de curta duração, onde não há expectativa de evolução significativa do domínio.
Também é perigoso adotá-lo quando o time não está confortável com o conceito. Abstrações mal compreendidas acabam sendo copiadas mecanicamente, gerando código difícil de entender e manter. Nesses casos, um if/else bem escrito, claro e bem testado é mais honesto e econômico.
Como minimizar o lado ruim
Algumas práticas ajudam a reduzir os impactos negativos do padrão. A primeira delas é investir em nomes extremamente claros. Uma classe como MinimumAmountSpecification ou VipCustomerSpecification deve explicar seu papel sem que seja necessário abrir o código.
Organização de pacotes também é fundamental. Separar Specifications por contexto ou agregado, como domain.specification.order ou domain.specification.customer, evita que o projeto vire um amontoado de classes soltas.
Outra boa prática é criar pontos de entrada legíveis, expondo regras de alto nível em vez de espalhar composições por todo o código. Dessa forma, os serviços passam a depender de conceitos claros do domínio, e não de detalhes de composição.
Por fim, o padrão deve ser aplicado onde realmente faz diferença. Para regras complexas e mutáveis, Specification agrega valor. Para validações simples, código direto continua sendo a melhor escolha.
A real diferença entre Specification e if/else
O que realmente muda não é a lógica, é o papel da dela
Com if/else, a regra é apenas controle de fluxo:
if (order.totalAmount >= 1000 && !order.international) {
approve(order)
}
Essa condição:
- não tem nome
- não pode ser reutilizada facilmente
- não “existe” fora desse método
- não é um conceito explícito do domínio
Ela é apenas um detalhe de implementação daquele fluxo específico.
Já com Specification, a lógica deixa de ser apenas controle de fluxo e passa a ser um objeto do domínio:
val autoApprovalSpec =
MinimumAmountSpecification(1000.0)
.and(InternationalOrderSpecification().not())
Aqui, a regra:
- tem identidade
- pode receber um nome
- pode ser reutilizada
- pode ser combinada
- pode ser testada isoladamente
Ou seja, não trocamos && por .and(), nós movemos a regra de dentro do fluxo para o modelo do domínio.
if/else mistura “o que decidir” com “o que fazer”
Em um código baseado em if/else, geralmente acontece isto:
fun approveOrder(order: Order) {
if (order.totalAmount >= 1000 && !order.international) {
reserveStock(order)
generateInvoice(order)
notifyCustomer(order)
} else {
sendToManualReview(order)
}
}
Aqui estão misturados:
- a regra de negócio (quando pode aprovar)
- o fluxo da aplicação (o que acontece se aprovar ou não)
Se amanhã a regra mudar, a gente precisa mexer no fluxo, mesmo que o fluxo em si continue válido.
Com Specification, nós separamos as duas coisas:
fun approveOrder(order: Order) {
if (autoApprovalSpec.isSatisfiedBy(order)) {
reserveStock(order)
generateInvoice(order)
notifyCustomer(order)
} else {
sendToManualReview(order)
}
}
Agora:
- o fluxo continua o mesmo
- apenas a regra muda
- a decisão vira uma dependência explícita
Essa separação é algo que if/else sozinho não oferece.
if/else não escala semanticamente
Com poucas condições, if/else é excelente.
Mas conforme o domínio cresce, começa a aparecer:
if (
(order.totalAmount >= 1000 && !order.international && customer.isVip) ||
(order.channel == ONLINE && order.urgent && !order.hasFraudAlert)
) {
...
}
Esse código é difícil de ler, e pior:
- ninguém sabe por quê essa lógica existe
- ela não tem nome
- ninguém ousa mexer sem medo de quebrar algo
Com Specification, nós transformamos isso em linguagem do domínio:
val vipAutoApproval = MinimumAmountSpecification(1000.0)
.and(NotInternationalSpecification())
.and(VipCustomerSpecification())
val onlineUrgentApproval = OnlineChannelSpecification()
.and(UrgentOrderSpecification())
.and(NoFraudAlertSpecification())
val approvalRule = vipAutoApproval.or(onlineUrgentApproval)
O que antes era um bloco lógico opaco vira documentação executável.
Specification cria composição, if/else cria acoplamento
Toda vez que a gente copia um if parecido em outro lugar, estamos criando uma duplicação invisível:
// service A
if (order.totalAmount >= 1000 && !order.international) { ... }
// service B
if (order.totalAmount >= 1000 && !order.international) { ... }
Se amanhã o valor mínimo virar 1200:
- vamos precisa lembrar de todos os lugares (e queira Alan Turing que exista testes unitários para os pontos que esquecermos de mudar…)
- erros de inconsistência aparecem com mais facilidade
Com Specification, nós compomos a regra uma vez e reaproveitamos:
val highValueDomesticOrder = MinimumAmountSpecification(1000.0)
.and(NotInternationalSpecification())
Esse objeto pode ser:
- injetado
- reutilizado
- versionado
- testado
Ou seja, Specification reduz acoplamento por duplicação.
if/else não é extensível sem modificação
if/else é inimigo do Open/Closed Principle:
if (conditionA) { ... }
else if (conditionB) { ... }
else if (conditionC) { ... }
Toda regra nova exige:
- modificar código existente
- aumentar complexidade ciclomática
- retestar tudo
Specification permite extensão por composição, não por modificação:
val newRule = existingRule.and(NewConditionSpecification())
Adicionamos um comportamento sem tocar no código que já funciona.
Specification é uma linguagem, if/else é apenas sintaxe
Este é o ponto mais importante.
if/else é:
- mecânica
- local
- imperativa
Specification constrói:
- uma linguagem de regras
- baseada no domínio
- declarativa
Vamos comparar:
if (order.totalAmount >= 1000 && !order.international && customer.isVip) { ... }
vs
if (VipAutoApprovalSpecification.isSatisfiedBy(order)) { ... }
No segundo caso:
- o porquê da decisão está explícito
- o nome carrega significado
- o código se explica
Conclusão
Em conclusão, o padrão Specification não deve ser enxergado como um substituto universal para if/else, nem como um requisito arquitetural obrigatório. Seu verdadeiro valor está em oferecer uma forma de tornar explícitas regras de negócio que já são, por natureza, complexas, mutáveis e combináveis.
Quando aplicado com critério, ele transforma decisões implícitas em conceitos do domínio, melhora a comunicação entre código e negócio e reduz o custo de evolução do sistema ao longo do tempo. Por outro lado, quando usado sem necessidade, adiciona abstração desnecessária e dificulta a leitura.
Com tudo isso, mais importante do que dominar sua implementação é desenvolver o julgamento arquitetural para decidir quando a clareza, a testabilidade e a flexibilidade oferecidas pelo Specification realmente compensam o custo adicional de abstração.