← Todos los patrones

Strategy

Behavioral · ver código fuente en GitHub

Leer en: English · Português · Español

Category: Behavioral

El problema

Un fragmento de comportamiento tiene varias variantes válidas, y cuál aplica depende de alguna condición en tiempo de ejecución — el modo de transporte, el tipo de transacción, el orden de clasificación. La primera implementación tentadora es un único método con un gran if/else o switch sobre esa condición. Funciona hasta que aparece la tercera o cuarta variante, momento en el que el método se vuelve largo, cada cambio arriesga romper una rama no relacionada, y agregar una variante nueva significa editar código que ya funciona en vez de solo agregar código nuevo al lado.

La solución

Extraer cada variante detrás de una interfaz común, y darle al código que la llama una forma de conectar la implementación que aplica — intercambiable en tiempo de ejecución, y cada variante es una clase autocontenida que se puede probar, leer y cambiar de forma aislada.

classDiagram
    class Strategy {
        <<interface>>
    }
    class ConcreteStrategyA
    class ConcreteStrategyB
    class Context {
        -strategy
        +setStrategy(s)
        +execute()
    }
    Strategy <|.. ConcreteStrategyA
    Strategy <|.. ConcreteStrategyB
    Context --> Strategy

Ejemplo clásico

classic/RouteStrategy calcula una ruta entre dos puntos; DrivingRouteStrategy, WalkingRouteStrategy y PublicTransportRouteStrategy cada una aplica un factor de desvío, velocidad y (para el transporte público) un tiempo de espera fijo distintos sobre el mismo cálculo de distancia en línea recta. Navigator es el contexto: mantiene una estrategia y le delega, y setStrategy(...) permite que quien llama cambie el modo de viaje para el mismo trayecto sin tocar el propio Navigator. NavigatorTest verifica tanto las matemáticas de cada estrategia como que cambiar de estrategia realmente cambia el resultado para el mismo par origen/destino.

Ejemplo aplicado: cálculo de tarifa por tipo de transacción

applied/FeeCalculator busca una FeeCalculationStrategy por TransactionType en vez de ramificar sobre él: PixFeeStrategy es gratuita (BACEN exige PIX gratuito entre personas físicas), TedFeeStrategy cobra una tarifa fija sin importar el monto, y BoletoFeeStrategy cobra un porcentaje con un piso mínimo. Este es precisamente el escenario para el que existe el patrón: un gateway de pagos real que agrega un cuarto tipo de transacción más adelante significa agregar una clase de estrategia nueva, no reabrir un método de cálculo de tarifa del que ya depende cada tipo de transacción existente. FeeCalculatorTest cubre las tres estrategias más el caso de falla de "tipo no registrado".

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:

./gradlew :behavioral:strategy:jacocoTestReport

Informe en behavioral/strategy/build/reports/jacoco/test/html/index.html.

Lecturas adicionales

Pruebas unitarias

src/test/java/com/designpatterns/behavioral/strategy/classic/NavigatorTest.java
package com.designpatterns.behavioral.strategy.classic;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class NavigatorTest {

    private final Location origin = new Location(0, 0);
    private final Location destination = new Location(3, 4); // straight-line distance = 5km

    @Test
    void drivingRouteAppliesTheRoadDetourFactor() {
        Navigator navigator = new Navigator(new DrivingRouteStrategy());

        Route route = navigator.route(origin, destination);

        assertThat(route.distanceKm()).isEqualTo(5 * 1.3);
        assertThat(route.description()).isEqualTo("driving");
    }

    @Test
    void swappingTheStrategyChangesTheRouteForTheSameTrip() {
        Navigator navigator = new Navigator(new WalkingRouteStrategy());
        Route walking = navigator.route(origin, destination);

        navigator.setStrategy(new PublicTransportRouteStrategy());
        Route transit = navigator.route(origin, destination);

        assertThat(walking.distanceKm()).isNotEqualTo(transit.distanceKm());
        assertThat(walking.estimatedMinutes()).isNotEqualTo(transit.estimatedMinutes());
    }
}
src/test/java/com/designpatterns/behavioral/strategy/applied/FeeCalculatorTest.java
package com.designpatterns.behavioral.strategy.applied;

import org.junit.jupiter.api.Test;

import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class FeeCalculatorTest {

    private final FeeCalculator calculator = FeeCalculator.withDefaultStrategies();

    @Test
    void pixTransfersAreFree() {
        assertThat(calculator.calculateFeeCents(TransactionType.PIX, 500_00L)).isZero();
    }

    @Test
    void tedChargesAFlatFeeRegardlessOfAmount() {
        assertThat(calculator.calculateFeeCents(TransactionType.TED, 100_00L)).isEqualTo(1000L);
        assertThat(calculator.calculateFeeCents(TransactionType.TED, 50_000_00L)).isEqualTo(1000L);
    }

    @Test
    void boletoChargesThePercentageFeeAboveTheMinimum() {
        assertThat(calculator.calculateFeeCents(TransactionType.BOLETO, 100_000_00L)).isEqualTo(2_000_00L);
    }

    @Test
    void boletoFallsBackToTheMinimumFeeForSmallAmounts() {
        assertThat(calculator.calculateFeeCents(TransactionType.BOLETO, 1_00L)).isEqualTo(350L);
    }

    @Test
    void rejectsAnUnregisteredTransactionType() {
        FeeCalculator calculatorWithoutBoleto = new FeeCalculator(Map.of(TransactionType.PIX, new PixFeeStrategy()));

        assertThatThrownBy(() -> calculatorWithoutBoleto.calculateFeeCents(TransactionType.BOLETO, 100L))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

Ver informe completo de cobertura JaCoCo →