Como separar regras de negócio que mudam em ritmos diferentes — e por que o switch é o lugar errado para elas.
Introdução
O Strategy é um Padrão de Projeto Comportamental, catalogado originalmente pela Gang of Four. Padrões comportamentais tratam de como objetos se comunicam e distribuem responsabilidades entre si — diferente dos criacionais, que tratam de como objetos são instanciados, e dos estruturais, que tratam de como objetos se compõem.
A definição formal é curta: o Strategy encapsula uma família de algoritmos intercambiáveis, cada um em sua própria classe, e permite que o algoritmo varie independentemente do cliente que o utiliza.
A definição prática é ainda mais curta: é o padrão que transforma a pergunta "qual regra eu aplico agora?" de uma estrutura condicional em uma escolha de objeto.
O problema: o switch que ninguém quer tocar
Todo sistema que cresce acumula um método específico. Ele começa com dois casos, ganha um terceiro em uma sprint apertada e, alguns anos depois, tem centenas de linhas e um comentário do tipo // não mexer sem falar com o time de faturamento.
O sintoma visível é feio, mas o sintoma não é o problema. O problema real é acoplamento temporal: regras que mudam em cadências independentes foram colocadas fisicamente na mesma unidade de código.
Quando a regra A muda, você é obrigado a abrir, recompilar e reimplantar o mesmo arquivo que executa a regra B — mesmo que B não tenha mudado nada.
Isso transforma cada alteração pequena em risco grande. E risco grande produz um comportamento defensivo previsível: ninguém quer mexer no método, então as pessoas contornam com condicionais extras em vez de corrigir a estrutura, e o método piora a cada iteração.
O Strategy ataca exatamente esse acoplamento. Ele separa o que varia do que permanece estável, colocando cada variação em uma classe própria.
Conceito: a analogia da fonte de energia
Pense em um notebook e nas tomadas do mundo. O notebook não tem, internamente, uma estrutura condicional perguntando "estou no Brasil? estou no Reino Unido? estou no Japão?" para decidir como converter a energia. Se tivesse, cada país novo exigiria um firmware novo no notebook.
O que existe é um contrato: a fonte entrega uma tensão estável ao notebook. Como essa tensão foi obtida — de 127V, 220V ou 240V — é problema de quem implementa o contrato, não de quem o consome.
O Strategy é isso em código:
- O contrato é uma interface.
- Cada variação do algoritmo é uma classe que implementa essa interface.
- O contexto (o notebook) conhece apenas a interface, nunca as implementações concretas.
A consequência importante: a lógica que decide como calcular fica isolada da lógica que decide quando calcular.
Estudo de caso: motor de cálculo fiscal em um sistema B2B
Para demonstrar o padrão em um contexto mais próximo dos desafios encontrados em sistemas corporativos, vamos utilizar como exemplo um motor de cálculo fiscal em uma plataforma B2B.
⚠️ Aviso: Os valores, alíquotas e fórmulas apresentados neste artigo são simplificados e possuem finalidade exclusivamente didática. Eles não devem ser utilizados para cálculo tributário real ou tomada de decisão fiscal.
O sistema legado
A plataforma emite notas para clientes de perfis diferentes, e o valor dos tributos depende do regime tributário do cliente: Simples Nacional, Lucro Presumido ou Lucro Real. O código original concentrava tudo em um único método:
public BigDecimal calcularImpostos(Pedido pedido, Cliente cliente) {
BigDecimal total = BigDecimal.ZERO;
if (cliente.getRegime().equals("SIMPLES")) {
// faixas de receita, parcela a deduzir, alíquota efetiva
} else if (cliente.getRegime().equals("PRESUMIDO")) {
// alíquotas fixas sobre a receita
} else if (cliente.getRegime().equals("REAL")) {
// alíquotas maiores, com desconto de créditos apropriados
}
return total;
}
Por que isso é um problema de engenharia, e não só código feio
Três características desse domínio tornam o acoplamento caro:
1. As regras mudam em datas diferentes e por motivos diferentes. Cada regime segue sua própria trilha legislativa. Uma alteração em um não tem relação nenhuma com o outro, mas ambos vivem no mesmo arquivo.
2. Os algoritmos consomem entradas diferentes. O Simples precisa da receita bruta acumulada do cliente para descobrir em qual faixa ele está. O Lucro Real precisa dos créditos apropriados no período. O Lucro Presumido não usa nenhum dos dois. Não existe "extrair um método comum" aqui, porque os inputs divergem.
3. O raio de explosão de um erro é o faturamento inteiro. Uma regressão introduzida ao ajustar a faixa do Simples pode derrubar a emissão de notas dos clientes de Lucro Real, porque compartilham o mesmo caminho de execução.
Esse é o formato clássico de problema que o Strategy resolve: variação de algoritmo, com cadência de mudança independente e alto custo de erro. A partir daqui, o foco é o padrão; a parte fiscal existe apenas para dar peso realista ao cenário.
Escopo do exemplo
Para manter o artigo focado no padrão, as alíquotas e faixas são constantes dentro de cada estratégia. Em um sistema real elas viriam de configuração versionada com data de vigência — uma decisão de infraestrutura ortogonal ao Strategy. Todo o código apresentado abaixo é Java puro e compila sem framework.
Representação visual
Figura 1 — Diagrama UML do Strategy Pattern aplicado ao motor fiscal.
O diagrama está restrito às classes que compõem o padrão. Pedido, Cliente e as exceções aparecem no código como tipos de apoio, mas incluí-los aqui tiraria o foco da estrutura que importa.
Repare no losango vazio, indicando agregação, entre MotorFiscal e CalculadoraImposto: o motor usa as estratégias, mas não as cria. Esse detalhe é o que separa um Strategy real de um switch disfarçado.
Figura 2 — Arquitetura de alto nível do sistema de cálculo fiscal.
Dois pontos que este desenho comunica e o UML não:
A consulta ao banco acontece antes do motor. É o dado do cliente que determina qual estratégia será executada — a seleção não é uma decisão tomada em código, é uma consequência de dados que chegam em tempo de execução.
O Módulo Fiscal é uma fronteira interna, não um serviço remoto. O Strategy aqui é uma decisão de organização interna. Ele não introduz nenhuma chamada de rede, e é importante que isso fique visível: padrões de projeto e padrões de arquitetura distribuída resolvem problemas diferentes.
Implementação em Java
Tipos de apoio
public enum RegimeTributario {
SIMPLES_NACIONAL,
LUCRO_PRESUMIDO,
LUCRO_REAL
}
public record Cliente(RegimeTributario regimeTributario, BigDecimal receitaBruta12Meses, String uf) {}
public record Pedido(BigDecimal valorTotal, BigDecimal creditosApropriados) {}
public class RegimeNaoSuportadoException extends RuntimeException {
public RegimeNaoSuportadoException(RegimeTributario regime) {
super("Nenhuma calculadora registrada para o regime: " + regime);
}
}
public class LimiteSimplesExcedidoException extends RuntimeException {
public LimiteSimplesExcedidoException(BigDecimal receita) {
super("Receita acumulada fora das faixas configuradas: " + receita);
}
}
O contrato
public record ContextoFiscal(
BigDecimal valorBruto,
BigDecimal receitaBruta12Meses,
BigDecimal creditosApropriados,
String uf
) {
public static ContextoFiscal de(Pedido pedido, Cliente cliente) {
return new ContextoFiscal(
pedido.valorTotal(),
cliente.receitaBruta12Meses(),
pedido.creditosApropriados(),
cliente.uf()
);
}
}
public record ResultadoImposto(
BigDecimal total,
RegimeTributario regimeAplicado,
Map<String, BigDecimal> detalhamento
) {}
public interface CalculadoraImposto {
ResultadoImposto calcular(ContextoFiscal contexto);
RegimeTributario getRegime();
}
O método getRegime() merece atenção. Ele existe para que cada estratégia se identifique, em vez de alguém externo precisar mapeá-la. É esse detalhe que permite eliminar a estrutura condicional de verdade, em vez de apenas empurrá-la para uma factory.
As estratégias
public class SimplesNacionalCalculadora implements CalculadoraImposto {
private record Faixa(BigDecimal limite, BigDecimal aliquotaNominal, BigDecimal parcelaDeduzir) {}
// Tabela reduzida e simplificada para fins didáticos.
private static final List<Faixa> FAIXAS = List.of(
new Faixa(bd("180000"), bd("0.0400"), bd("0")),
new Faixa(bd("360000"), bd("0.0730"), bd("5940")),
new Faixa(bd("720000"), bd("0.0950"), bd("13860")),
new Faixa(bd("1800000"), bd("0.1070"), bd("22500"))
);
@Override
public ResultadoImposto calcular(ContextoFiscal contexto) {
BigDecimal receita = contexto.receitaBruta12Meses();
Faixa faixa = FAIXAS.stream()
.filter(f -> receita.compareTo(f.limite()) <= 0)
.findFirst()
.orElseThrow(() -> new LimiteSimplesExcedidoException(receita));
// Alíquota efetiva = (receita x alíquota nominal - parcela a deduzir) / receita
BigDecimal aliquotaEfetiva = receita.multiply(faixa.aliquotaNominal())
.subtract(faixa.parcelaDeduzir())
.divide(receita, 6, RoundingMode.HALF_UP);
BigDecimal imposto = contexto.valorBruto()
.multiply(aliquotaEfetiva)
.setScale(2, RoundingMode.HALF_UP);
return new ResultadoImposto(
imposto,
RegimeTributario.SIMPLES_NACIONAL,
Map.of("IMPOSTO_UNIFICADO", imposto, "ALIQUOTA_EFETIVA", aliquotaEfetiva)
);
}
@Override
public RegimeTributario getRegime() {
return RegimeTributario.SIMPLES_NACIONAL;
}
private static BigDecimal bd(String valor) {
return new BigDecimal(valor);
}
}
public class LucroPresumidoCalculadora implements CalculadoraImposto {
// Alíquotas ilustrativas.
private static final BigDecimal ALIQUOTA_A = new BigDecimal("0.0065");
private static final BigDecimal ALIQUOTA_B = new BigDecimal("0.0300");
@Override
public ResultadoImposto calcular(ContextoFiscal contexto) {
BigDecimal base = contexto.valorBruto();
BigDecimal tributoA = base.multiply(ALIQUOTA_A).setScale(2, RoundingMode.HALF_UP);
BigDecimal tributoB = base.multiply(ALIQUOTA_B).setScale(2, RoundingMode.HALF_UP);
return new ResultadoImposto(
tributoA.add(tributoB),
RegimeTributario.LUCRO_PRESUMIDO,
Map.of("TRIBUTO_A", tributoA, "TRIBUTO_B", tributoB)
);
}
@Override
public RegimeTributario getRegime() {
return RegimeTributario.LUCRO_PRESUMIDO;
}
}
public class LucroRealCalculadora implements CalculadoraImposto {
// Alíquotas ilustrativas.
private static final BigDecimal ALIQUOTA_A = new BigDecimal("0.0165");
private static final BigDecimal ALIQUOTA_B = new BigDecimal("0.0760");
@Override
public ResultadoImposto calcular(ContextoFiscal contexto) {
BigDecimal base = contexto.valorBruto().subtract(contexto.creditosApropriados())
.max(BigDecimal.ZERO);
BigDecimal tributoA = base.multiply(ALIQUOTA_A).setScale(2, RoundingMode.HALF_UP);
BigDecimal tributoB = base.multiply(ALIQUOTA_B).setScale(2, RoundingMode.HALF_UP);
return new ResultadoImposto(
tributoA.add(tributoB),
RegimeTributario.LUCRO_REAL,
Map.of("TRIBUTO_A", tributoA, "TRIBUTO_B", tributoB,
"CREDITOS", contexto.creditosApropriados())
);
}
@Override
public RegimeTributario getRegime() {
return RegimeTributario.LUCRO_REAL;
}
}
LucroRealCalculadora usa creditosApropriados e ignora receitaBruta12Meses; SimplesNacionalCalculadora faz o oposto.
Essa assimetria é a justificativa técnica do padrão: não são parâmetros diferentes do mesmo algoritmo, são algoritmos diferentes.
O contexto: onde a condicional desaparece
public class MotorFiscal {
private final Map<RegimeTributario, CalculadoraImposto> estrategias;
public MotorFiscal(List<CalculadoraImposto> calculadoras) {
this.estrategias = calculadoras.stream()
.collect(Collectors.toUnmodifiableMap(
CalculadoraImposto::getRegime,
Function.identity()
));
}
public ResultadoImposto calcularPara(Pedido pedido, Cliente cliente) {
CalculadoraImposto calculadora = estrategias.get(cliente.regimeTributario());
if (calculadora == null) {
throw new RegimeNaoSuportadoException(cliente.regimeTributario());
}
return calculadora.calcular(ContextoFiscal.de(pedido, cliente));
}
}
Este é o coração do artigo. Duas decisões merecem ser explicadas.
Collectors.toUnmodifiableMap falha na inicialização se houver regime duplicado. Se alguém criar por engano uma segunda calculadora para LUCRO_REAL, o coletor lança IllegalStateException na construção do objeto. Isso é bem melhor do que descobrir em produção que parte das notas usou a implementação errada.
A escolha entre exceção e valor padrão é uma decisão de domínio, não do padrão. Aqui a exceção é deliberada: em cálculo tributário, "não sei calcular" precisa interromper a operação, porque um valor padrão silencioso geraria um documento incorreto. Em outros domínios — formatação de texto, política de desconto — um comportamento padrão neutro seria a escolha certa.
Vale notar que o construtor recebe uma List justamente para desacoplar o motor de quem monta as estratégias. Em uma aplicação com injeção de dependência, essa lista é preenchida automaticamente; em Java puro, ela é passada na composição da aplicação. O motor não muda em nenhum dos dois casos.
O que realmente muda ao adicionar um regime novo
É comum ler que o Strategy permite estender o sistema "sem alterar nenhuma linha de código existente". Na implementação acima, isso não é literalmente verdade, e vale ser preciso.
Para adicionar um regime novo, é preciso:
- Acrescentar uma constante em
RegimeTributario— uma linha, aditiva. - Criar uma classe nova implementando
CalculadoraImpostoe registrá-la na composição da aplicação.
O que não muda:
-
MotorFiscalnão é tocado. - Nenhuma das calculadoras existentes é tocada.
- O código que consome o resultado não é tocado.
A diferença em relação ao switch original é qualitativa, não quantitativa. Lá, você também acrescentaria uma constante — mas depois editaria o mesmo método que executa o cálculo de todos os outros regimes. É essa segunda edição, no código que está em produção e funcionando, que carrega o risco de regressão.
O Strategy não elimina toda alteração; ele elimina a alteração perigosa.
Rigorosamente, o enum ainda é um ponto de extensão que exige modificação, e é justo chamar isso de violação parcial do Princípio Aberto/Fechado. Existe alternativa: usar String como chave do mapa, lida do banco de dados. Isso removeria até essa linha, ao custo de perder a verificação em tempo de compilação. Manter o enum é um trade-off consciente — em domínio fiscal, um regime escrito errado falhando no build é preferível a falhar em produção.
Testes
Cada estratégia é testável isoladamente, sem instanciar as outras. No método monolítico original, testar um regime exigia carregar todos juntos.
class MotorFiscalTest {
private final MotorFiscal motor = new MotorFiscal(List.of(
new SimplesNacionalCalculadora(),
new LucroPresumidoCalculadora(),
new LucroRealCalculadora()
));
@Test
void deveSelecionarEstrategiaConformeRegimeDoCliente() {
var cliente = new Cliente(RegimeTributario.LUCRO_PRESUMIDO, new BigDecimal("500000"), "GO");
var pedido = new Pedido(new BigDecimal("10000.00"), BigDecimal.ZERO);
ResultadoImposto resultado = motor.calcularPara(pedido, cliente);
assertThat(resultado.total()).isEqualByComparingTo("365.00");
assertThat(resultado.regimeAplicado()).isEqualTo(RegimeTributario.LUCRO_PRESUMIDO);
}
@Test
void deveAplicarAliquotaEfetivaNoSimplesNacional() {
var cliente = new Cliente(RegimeTributario.SIMPLES_NACIONAL, new BigDecimal("500000"), "GO");
var pedido = new Pedido(new BigDecimal("10000.00"), BigDecimal.ZERO);
ResultadoImposto resultado = motor.calcularPara(pedido, cliente);
assertThat(resultado.total()).isEqualByComparingTo("672.80");
}
@Test
void deveFalharQuandoNaoHaEstrategiaParaORegime() {
MotorFiscal motorIncompleto = new MotorFiscal(List.of(new LucroPresumidoCalculadora()));
var cliente = new Cliente(RegimeTributario.LUCRO_REAL, new BigDecimal("500000"), "GO");
var pedido = new Pedido(new BigDecimal("10000.00"), BigDecimal.ZERO);
assertThatThrownBy(() -> motorIncompleto.calcularPara(pedido, cliente))
.isInstanceOf(RegimeNaoSuportadoException.class);
}
}
O terceiro teste é o mais interessante: ele documenta o comportamento na ausência de estratégia, que é justamente a decisão de domínio discutida acima.
Prós e contras
Vantagens
- Extensão com risco contido. Adicionar um regime significa uma constante nova e uma classe nova. Nenhum algoritmo existente é editado.
- Raio de explosão reduzido. Uma alteração no cálculo do Simples não pode quebrar o Lucro Real: não compartilham arquivo, estado nem caminho de execução.
- Testabilidade real. Cada algoritmo é uma unidade isolada, com entradas e saídas explícitas.
- Seleção dirigida por dados. Como a escolha vem do cadastro do cliente e não de código, fica natural evoluir para cenários como aplicar a regra vigente na data de emissão.
Custos e trade-offs
- Dispersão da lógica. Antes, todas as regras estavam em uma tela; agora estão em vários arquivos. Para quem chega no time, "onde o imposto é calculado?" ficou mais difícil de responder.
-
Observabilidade vira requisito. Com a condicional, bastava ler o código para saber o que executou. Agora é preciso registrar qual implementação atendeu qual pedido — incluir
regimeAplicadonoResultadoImpostoé resposta direta a esse custo. -
O objeto de contexto tende a inchar.
ContextoFiscalcarrega a união dos campos que qualquer estratégia possa precisar, então cada implementação ignora parte deles. É um efeito colateral conhecido do Strategy com contexto compartilhado. - Mais tipos para manter. Cinco classes onde havia um método. Em uma base pequena e estável, isso é cerimônia sem retorno.
E o trade-off mais importante: se a variação for de dados, Strategy é overengineering. Uma alíquota que muda por unidade da federação não merece uma classe por estado — isso é tabela de configuração.
O sinal prático para distinguir: se as implementações diferem apenas por constantes, você quer uma tabela. Se diferem nos campos que consomem e nos passos que executam, você quer Strategy. Foi esse teste que justificou o padrão aqui.
Conclusão
O Strategy não resolve o problema de "código feio com muitas condicionais". Ele resolve um problema arquitetural mais específico: regras de negócio que evoluem em ritmos independentes acopladas na mesma unidade de código.
No motor fiscal, o ganho não é estético. É transformar uma alteração de alto risco — mexer no cálculo de todos os clientes para atender à mudança de um regime — em uma alteração isolada e testável. O padrão desloca a modificação para o lugar barato, uma constante em um enum, e a tira do lugar caro, o algoritmo em produção.
Visão pessoal
Como estudante e desenvolvedor em formação, minha experiência com Padrões de Projeto até a realização deste estudo era majoritariamente teórica. Ao ler a literatura técnica, a princípio, o Strategy pode parecer apenas uma forma desnecessariamente complexa e verbosa de escrever uma estrutura condicional simples.
No entanto, o exercício de modelar e implementar este cenário de cálculo tributário mudou completamente minha perspectiva. A verdadeira virada de chave foi ver o Princípio Aberto/Fechado funcionando na prática: a compreensão de que podemos expandir as capacidades de um sistema inteiro apenas adicionando novas classes, sem a necessidade de alterar ou arriscar quebrar o código do motor principal.
Escrever este artigo também me obrigou a ser mais preciso do que eu esperava. Ao revisar a implementação, percebi que a afirmação comum de que o Strategy dispensa qualquer alteração no código existente não se sustenta inteiramente, já que o enum de regimes ainda precisa receber uma constante nova. Entender por que essa alteração é aceitável — e por que ela é qualitativamente diferente de editar o algoritmo em produção — foi provavelmente o que mais me ensinou no processo.
Ainda que eu não tenha vivenciado o cenário crítico de manter um sistema legado complexo em produção e lidar com as regressões causadas por um método fortemente acoplado, essa imersão prática cumpriu seu objetivo principal. Ela me forneceu a base arquitetural não apenas para entender como resolver esse tipo de problema, mas, principalmente, para evitar que eu construa sistemas com essas falhas de design no futuro.
E você?
Fica a pergunta que mais me interessa: qual foi a estrutura condicional mais assustadora que você já encontrou em produção — e você refatorou ou concluiu que o risco não valia a pena?
Também quero ouvir discordância: em que situação você acha que manter o switch foi a decisão certa? Comenta aí.
This article was originally published by DEV Community and written by Leticia Reis.
Read original article on DEV Community
