Strategy Pattern: reemplaza if-else con una interfaz

1. Descripción general

Una larga cadena de if-else o switch para elegir comportamiento según tipo es una señal de código próximo a ser difícil de mantener. Cada vez que agregas un tipo nuevo, debes modificar la misma función, violando el Open/Closed Principle. Este artículo te enseña cómo usar Strategy Pattern para separar cada comportamiento en una clase propia, implementando la misma interfaz, y dejar que Spring elija automáticamente la estrategia adecuada en lugar de escribir if-else. Después de leerlo, podrás refactorizar un servicio de cálculo de tarifas/descuentos que está desordenado.

2. Problema: if-else se expande con el tiempo

Supongamos que tienes un PaymentService que calcula la tarifa de transacción según el método de pago:

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("Tipo no soportado: " + paymentType);
        }
    }
}

Problemas:

  • Agregar un nuevo método de pago (por ejemplo CRYPTO) requiere modificar directamente este método.
  • La lógica de cálculo de tarifas de cada tipo se mezcla en una función, difícil de probar de forma aislada.
  • Usar String para comparaciones es propenso a errores de ortografía, sin verificación en tiempo de compilación.

3. Solución: define una interfaz Strategy

La idea central: extraer “comportamiento que cambia según el tipo” en una interfaz, donde cada tipo es una implementación separada.

public interface FeeStrategy {
    double calculateFee(double amount);
}

Ahora cada estrategia es una clase independiente, fácil de leer y de probar:

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;
    }
}

Ahora PaymentService solo necesita recibir una FeeStrategy y llamarla, sin conocer la 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 se inyecta a través del constructor, PaymentService no necesita saber si es CreditCardFeeStrategy o cualquier otro tipo.
  • Agregar un nuevo método de pago = añadir una nueva clase que implemente FeeStrategy, sin modificar PaymentService.

4. Elige la estrategia correctamente: evita if-else en otro lugar

Problema común: eliminas if-else de PaymentService pero lo reconstruyes en el lugar que llama al servicio. Para evitar esto, usa un Map<String, FeeStrategy> como registro, registrando estrategias por clave en el punto de inicialización (factory), sin dispersar if-else en todo el 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("Tipo no soportado: " + paymentType);
        }
        return strategy;
    }
}
  • strategies es un mapa inmutable, construido una sola vez cuando se inicializa la factory.
  • resolve() es el único lugar que queda con “condición” — pero solo busca en el mapa, sin contener lógica de negocios. La lógica real está en cada estrategia.

Pruébalo:

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

Nota: si la lista de estrategias es fija y cambia poco (2-3 tipos), un if-else simple sigue siendo aceptable. Strategy Pattern demuestra su valor cuando el número de tipos crece gradualmente o la lógica de cada tipo es compleja, necesitando pruebas aisladas.

5. Aprovecha Spring: deja que el contenedor inyecte automáticamente la lista de estrategias

En Spring Boot, no necesitas escribir el Map manualmente. Marca cada estrategia como un @Component, y Spring recopilará automáticamente todos los beans que implementen la interfaz en una List.

public interface FeeStrategy {
    String getPaymentType();
    double calculateFee(double amount);
}

Agrega getPaymentType() para que cada estrategia declare por sí sola qué tipo sirve:

@Component
public class CreditCardFeeStrategy implements FeeStrategy {

    @Override
    public String getPaymentType() {
        return "CREDIT_CARD";
    }

    @Override
    public double calculateFee(double amount) {
        return amount * 0.03;
    }
}

La factory ahora recibe List<FeeStrategy> inyectada por Spring, construyendo el mapa en el constructor:

@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("Tipo no soportado: " + paymentType);
        }
        return strategy;
    }
}
  • Spring escanea todos los beans que implementan FeeStrategy, poniéndolos en strategyList en el orden correcto de inicialización.
  • El constructor construye el mapa una sola vez cuando se crea FeeStrategyFactory — sin if-else en todo el flujo de selección de estrategia.
  • Agregar una nueva CryptoFeeStrategy: solo crea la clase, agrega @Component, y Spring la detecta automáticamente. FeeStrategyFactory no necesita reconstruirse.

PaymentService usa esta factory a través de @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. Escribe pruebas para cada estrategia y para la factory

Dado que cada estrategia es una clase independiente, puedes probar cada una por separado sin necesidad de ningún mock:

class CreditCardFeeStrategyTest {

    private final CreditCardFeeStrategy strategy = new CreditCardFeeStrategy();

    @Test
    void shouldCalculate3PercentFee() {
        double fee = strategy.calculateFee(1_000_000);

        assertThat(fee).isEqualTo(30_000.0);
    }
}

Las pruebas para la factory aseguran que la “selección de estrategia” funcione correctamente, sin caer en un if-else incorrecto:

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 la factory devuelva el tipo correcto de estrategia según la clave.
  • shouldThrowWhenTypeNotSupported asegura que cuando no se encuentra una estrategia, aún se lance un error claro, como el comportamiento del else anterior — pero ahora concentrado en un único lugar.

7. Usa Enum en lugar de String key (opción más segura)

Si te preocupan los errores de ortografía con claves String, puedes usar enum como clave para el mapa, ayudando al compilador a detectar errores temprano:

public enum PaymentType {
    CREDIT_CARD, BANK_TRANSFER, E_WALLET
}
public interface FeeStrategy {
    PaymentType getPaymentType();
    double calculateFee(double amount);
}

Este enfoque mantiene toda la arquitectura anterior, solo cambiando el tipo de clave en Map<PaymentType, FeeStrategy>. El IDE te sugerirá completamente los valores del enum, evitando que escribas mal la cadena.

8. Cuándo NO usar Strategy Pattern

  • Solo hay 2 ramas de lógica, casi ninguna capacidad de expansión — un if-else simple es más fácil de leer que crear 2-3 clases adicionales.
  • La lógica de las ramas es idéntica en un 90%, solo difieren algunos parámetros — usar strategy creará muchas clases casi duplicadas. En este caso, un enum con campos de configuración (por ejemplo, la tasa de tarifa) suele ser más compacto.
  • El comportamiento no se elige según un “tipo” fijo sino que depende de muchas condiciones runtime complejas (múltiples criterios combinados) — en ese caso, Strategy Pattern simple no es suficiente, debes considerar Chain of Responsibility o un motor de reglas.

Nota: no apliques el patrón solo porque “se vea más profesional”. Solo úsalo cuando veas claramente que el número de ramas aumentará con el tiempo o la lógica de cada rama es lo suficientemente compleja como para necesitar pruebas aisladas.

9. Conclusión

Strategy Pattern resuelve correctamente el problema de las largas cadenas de if-else: separa el comportamiento que cambia según tipo en clases separadas que implementan la misma interfaz. En Spring Boot, aprovechas el mecanismo de inyección List<Bean> para recopilar automáticamente las estrategias, sin necesidad de gestionar manualmente el registro. El resultado es que agregar un nuevo tipo no requiere modificar el código anterior, cada estrategia se prueba fácilmente de forma aislada, y el lugar de “selección de estrategia” es único — una búsqueda en el mapa, sin if-else disperso en toda la base de código.

design-patternjavaspring-bootclean-code