Strategy Pattern: substitua if-else por uma interface
1. Visão geral
Uma série longa de if-else ou switch para escolher comportamentos por tipo é um sinal de que o código está prestes a ficar difícil de manter. Cada vez que você adiciona um novo tipo, precisa modificar a mesma função, violando o Princípio Aberto/Fechado. Este artigo mostra como usar o Strategy Pattern para separar cada comportamento em uma classe própria, implementando a mesma interface, e deixar o Spring escolher a estratégia apropriada em vez de você escrever if-else. Depois de ler, você será capaz de refatorar por conta própria um serviço que calcula taxas/descontos desorganizado.
2. O problema: if-else crescendo ao longo do tempo
Suponha que você tem um PaymentService que calcula as taxas de transação de acordo com o método de pagamento:
public class PaymentService {
public double calculateFee(String paymentType, double amount) {
if (paymentType.equals("CREDIT_CARD")) {
return amount * 0.03;
} else if (paymentType.equals("BANK_TRANSFER")) {
return amount * 0.01;
} else if (paymentType.equals("E_WALLET")) {
return amount * 0.015;
} else {
throw new IllegalArgumentException("Unsupported type: " + paymentType);
}
}
}
Problemas:
- Adicionar um novo método de pagamento (por exemplo, CRYPTO) requer modificar diretamente esse método.
- A lógica de cálculo de taxa de cada tipo está misturada em uma única função, difícil de testar isoladamente.
- Usar String para comparação é propenso a erros de digitação, sem verificação em tempo de compilação.
3. Solução: defina uma interface Strategy
A ideia central: extrair o “comportamento que varia por tipo” em uma interface, cada tipo é uma implementação separada.
public interface FeeStrategy {
double calculateFee(double amount);
}
Cada estratégia agora é uma classe independente, fácil de ler e de testar:
public class CreditCardFeeStrategy implements FeeStrategy {
@Override
public double calculateFee(double amount) {
return amount * 0.03;
}
}
public class BankTransferFeeStrategy implements FeeStrategy {
@Override
public double calculateFee(double amount) {
return amount * 0.01;
}
}
public class EWalletFeeStrategy implements FeeStrategy {
@Override
public double calculateFee(double amount) {
return amount * 0.015;
}
}
PaymentService agora só precisa receber uma FeeStrategy e chamá-la, sem saber nada sobre a lógica interna:
public class PaymentService {
private final FeeStrategy feeStrategy;
public PaymentService(FeeStrategy feeStrategy) {
this.feeStrategy = feeStrategy;
}
public double calculateFee(double amount) {
return feeStrategy.calculateFee(amount);
}
}
- feeStrategy é injetada via construtor, PaymentService não precisa saber se é CreditCardFeeStrategy ou qualquer outro tipo.
- Adicionar um novo método de pagamento = adicionar uma nova classe implementando FeeStrategy, sem modificar PaymentService.
4. Escolher a estratégia corretamente: evite if-else em outro lugar
Um problema comum: você remove if-else de PaymentService, mas o reconstrói no local que chama o serviço. Para evitar isso, use um Map<String, FeeStrategy> como registro, registrando estratégias por chave no ponto de inicialização (factory), em vez de espalhar if-else por todo o código.
public class FeeStrategyFactory {
private final Map<String, FeeStrategy> strategies;
public FeeStrategyFactory() {
this.strategies = Map.of(
"CREDIT_CARD", new CreditCardFeeStrategy(),
"BANK_TRANSFER", new BankTransferFeeStrategy(),
"E_WALLET", new EWalletFeeStrategy()
);
}
public FeeStrategy resolve(String paymentType) {
FeeStrategy strategy = strategies.get(paymentType);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported type: " + paymentType);
}
return strategy;
}
}
- strategies é um mapa imutável, construído uma única vez na inicialização da factory.
- resolve() é o único lugar onde ainda há “ramificação condicional” — mas apenas consulta o mapa, não contém lógica de negócio. A lógica real está em cada estratégia.
Experimente:
FeeStrategyFactory factory = new FeeStrategyFactory();
FeeStrategy strategy = factory.resolve("E_WALLET");
PaymentService service = new PaymentService(strategy);
double fee = service.calculateFee(1_000_000);
System.out.println(fee);
15000.0
Observação: se a lista de estratégias é fixa e raramente muda (2–3 tipos), um if-else simples está tudo bem. O Strategy Pattern vale a pena quando o número de tipos cresce gradualmente ou a lógica de cada tipo é complexa, exigindo testes separados.
5. Aproveite o Spring: deixe o container injetar a lista de estratégias automaticamente
Em Spring Boot, você não precisa escrever o Map manualmente. Marque cada estratégia como um @Component, e o Spring coletará automaticamente todos os beans da mesma interface em uma List.
public interface FeeStrategy {
String getPaymentType();
double calculateFee(double amount);
}
Adicione getPaymentType() para que cada estratégia declare qual tipo ela atende:
@Component
public class CreditCardFeeStrategy implements FeeStrategy {
@Override
public String getPaymentType() {
return "CREDIT_CARD";
}
@Override
public double calculateFee(double amount) {
return amount * 0.03;
}
}
A factory agora recebe List
@Component
public class FeeStrategyFactory {
private final Map<String, FeeStrategy> strategies = new HashMap<>();
public FeeStrategyFactory(List<FeeStrategy> strategyList) {
for (FeeStrategy strategy : strategyList) {
strategies.put(strategy.getPaymentType(), strategy);
}
}
public FeeStrategy resolve(String paymentType) {
FeeStrategy strategy = strategies.get(paymentType);
if (strategy == null) {
throw new IllegalArgumentException("Unsupported type: " + paymentType);
}
return strategy;
}
}
- O Spring verifica todos os beans que implementam FeeStrategy, passando-os para strategyList na ordem correta de inicialização.
- O construtor constrói o mapa uma única vez quando FeeStrategyFactory é criada — sem nenhum if-else em todo o fluxo de seleção de estratégia.
- Adicione uma nova CryptoFeeStrategy: basta criar a classe, adicionar @Component, e o Spring a reconhecerá automaticamente. FeeStrategyFactory não precisa ser reconstruída.
PaymentService usa essa factory via @Service:
@Service
public class PaymentService {
private final FeeStrategyFactory feeStrategyFactory;
public PaymentService(FeeStrategyFactory feeStrategyFactory) {
this.feeStrategyFactory = feeStrategyFactory;
}
public double calculateFee(String paymentType, double amount) {
FeeStrategy strategy = feeStrategyFactory.resolve(paymentType);
return strategy.calculateFee(amount);
}
}
6. Escreva testes para cada estratégia e para a factory
Como cada estratégia é uma classe independente, você testa cada uma isoladamente sem precisar de mocks:
class CreditCardFeeStrategyTest {
private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();
@Test
void shouldCalculate3PercentFee() {
double fee = strategy.calculateFee(1_000_000);
assertThat(fee).isEqualTo(30_000.0);
}
}
Os testes para a factory garantem que a “seleção de estratégia” funciona corretamente, sem cair em um if-else errado:
class FeeStrategyFactoryTest {
private final FeeStrategyFactory factory = new FeeStrategyFactory(
List.of(new CreditCardFeeStrategy(), new EWalletFeeStrategy())
);
@Test
void shouldResolveCorrectStrategy() {
FeeStrategy strategy = factory.resolve("E_WALLET");
assertThat(strategy).isInstanceOf(EWalletFeeStrategy.class);
}
@Test
void shouldThrowWhenTypeNotSupported() {
assertThatThrownBy(() -> factory.resolve("CRYPTO"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("CRYPTO");
}
}
- shouldResolveCorrectStrategy confirma que a factory retorna o tipo correto de estratégia por chave.
- shouldThrowWhenTypeNotSupported garante que o caso de tipo não encontrado ainda lance um erro claro, como o comportamento else antigo — mas agora concentrado em um único lugar.
7. Use Enum em vez de String key (opção mais segura)
Se se preocupa com erros de digitação com String key, você pode usar um enum como chave do mapa, ajudando o compilador a pegar erros mais cedo:
public enum PaymentType {
CREDIT_CARD, BANK_TRANSFER, E_WALLET
}
public interface FeeStrategy {
PaymentType getPaymentType();
double calculateFee(double amount);
}
Essa abordagem mantém toda a arquitetura acima, apenas alterando o tipo de chave em Map<PaymentType, FeeStrategy>. A IDE fornecerá sugestões completas dos valores enum, evitando digitar strings incorretas.
8. Quando NÃO usar o Strategy Pattern
- Há apenas 2 ramos de lógica, pouca chance de expansão — um simples if-else é mais fácil de ler do que criar 2–3 classes extras.
- A lógica dos ramos é 90% semelhante, diferindo apenas em alguns parâmetros — usar estratégia criaria muitas classes quase duplicadas. Nesse caso, um enum com campos de configuração (por exemplo, taxa) é geralmente mais compacto.
- O comportamento não é selecionado por um “tipo” fixo, mas depende de muitas condições runtime complexas (múltiplos critérios combinados) — então o Strategy Pattern simples não é suficiente, você deve considerar Chain of Responsibility ou um rule engine.
Observação: não aplique o padrão apenas porque parece “mais profissional”. Use-o apenas quando você vê claramente que o número de ramos crescerá ao longo do tempo ou que a lógica de cada ramo é complexa o suficiente para exigir testes separados.
9. Conclusão
O Strategy Pattern resolve corretamente o problema de longas cadeias de if-else: separa o comportamento que varia por tipo em classes distintas, todas implementando a mesma interface. Em Spring Boot, você aproveita o mecanismo de injetar List