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
Stringpara 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);
}
}
feeStrategyse inyecta a través del constructor,PaymentServiceno necesita saber si esCreditCardFeeStrategyo cualquier otro tipo.- Agregar un nuevo método de pago = añadir una nueva clase que implemente
FeeStrategy, sin modificarPaymentService.
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;
}
}
strategieses 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-elsesimple 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 enstrategyListen el orden correcto de inicialización. - El constructor construye el mapa una sola vez cuando se crea
FeeStrategyFactory— sinif-elseen todo el flujo de selección de estrategia. - Agregar una nueva
CryptoFeeStrategy: solo crea la clase, agrega@Component, y Spring la detecta automáticamente.FeeStrategyFactoryno 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");
}
}
shouldResolveCorrectStrategyconfirma que la factory devuelva el tipo correcto de estrategia según la clave.shouldThrowWhenTypeNotSupportedasegura que cuando no se encuentra una estrategia, aún se lance un error claro, como el comportamiento delelseanterior — 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-elsesimple 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
enumcon 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.