Strategy Pattern en Symfony: cuándo usarlo y casos de uso reales

Ilustración del Strategy Pattern en Symfony con diferentes estrategias conectadas a una aplicación.

El Strategy Pattern es uno de esos patrones que tienen sentido cuando el problema acompaña.

La idea es simple: tienes una misma intención de negocio, pero varias formas de ejecutarla. Mientras eso se mantiene pequeño, normalmente no pasa nada. El problema empieza cuando esa variación crece, se mezcla en una clase central y cada caso nuevo obliga a seguir acumulando decisiones en el mismo sitio.

En proyectos Symfony esto pasa bastante: una operación que cambia según el canal, el proveedor, el formato o una regla concreta de negocio. Y ahí es donde Strategy puede ser una forma útil de separar comportamientos sin complicar más de la cuenta el diseño.

La clave no está en usar el patrón porque exista, sino en identificar cuándo ayuda de verdad a repartir responsabilidades y a mantener el código en un estado razonable.

El problema real no es el if

Un if no es un problema.

Dos, tampoco.

El problema empieza cuando una misma intención de negocio puede ejecutarse de varias formas y toda esa variación acaba concentrada en una única clase:

  • según el canal;
  • según el proveedor;
  • según el tipo de cliente;
  • según el país;
  • según el formato;
  • según la operación;
  • según el contexto de ejecución.

Ahí es donde el código empieza a degradarse de una forma bastante predecible:

  • la clase crece;
  • el constructor empieza a llenarse de dependencias;
  • añadir un caso nuevo obliga a tocar código antiguo;
  • testear una variante concreta empieza a ser incómodo;
  • y el servicio principal se convierte en un punto de acumulación de decisiones.

Ese es el escenario donde Strategy suele empezar a tener sentido.

Qué es el Strategy Pattern

La idea es bastante simple: si tienes varios comportamientos para una misma intención, no metas toda la lógica en una clase central.

Defines una interfaz común, creas una implementación por variante y delegas la ejecución en la que corresponda.

En otras palabras:

  • una misma intención;
  • varias implementaciones posibles;
  • una selección en runtime según el contexto.

Lo valioso del patrón no está en la definición teórica, sino en lo que evita: clases con demasiadas responsabilidades, condicionales que crecen sin parar y lógica difícil de extender o testear.

No todo comportamiento variable necesita un Strategy

Aquí conviene parar un momento, porque es justo el punto donde más fácil es sobrediseñar.

Ver variación de comportamiento y saltar automáticamente a Strategy suele ser un error. En algunos casos, la mejor solución no es un resolvedor con varias estrategias, sino polimorfismo.

Si el comportamiento pertenece de forma natural al propio objeto, seguramente tiene más sentido que viva ahí.

Por ejemplo, un cálculo de coste de envío según transportista no es el mejor caso para justificar un Strategy. Si cada transportista tiene su propia lógica y eso forma parte natural de su responsabilidad, probablemente tendría más sentido modelarlo así:

interface Carrier
{
    public function calculateShippingCost(float $weight): float;
}
final class SeurCarrier implements Carrier
{
    public function calculateShippingCost(float $weight): float
    {
        return 5 + ($weight * 0.80);
    }
}
final class DhlCarrier implements Carrier
{
    public function calculateShippingCost(float $weight): float
    {
        return 7 + ($weight * 0.65);
    }
}

Y el consumo sería directo:

$cost = $carrier->calculateShippingCost($weight);

Aquí no hace falta una capa adicional para recorrer estrategias ni un supports() para decidir cuál aplica. El comportamiento ya está donde debería estar.

Éste es un matiz importante: no se trata de elegir entre if y Strategy, sino de elegir la abstracción correcta.

Y a veces la abstracción correcta no es Strategy sino simplemente un modelo mejor.

Cuándo sí merece la pena usar Strategy

Strategy encaja mejor cuando la selección del comportamiento no pertenece de forma natural a una entidad concreta, sino que se decide desde fuera, por contexto, por reglas de aplicación o por composición dinámica.

Por ejemplo:

  • cuando una política de negocio cambia según condiciones externas;
  • cuando varias implementaciones comparten una intención pero no forman parte del mismo modelo de dominio;
  • cuando estás desacoplando integraciones con terceros;
  • cuando la decisión se toma en un servicio de aplicación;
  • cuando quieres añadir variantes sin reabrir una clase central.

En ese tipo de escenarios sí suele compensar.

Por qué encaja tan bien en Symfony

Symfony ayuda bastante a implementar este patrón de forma limpia porque el contenedor de servicios ya está pensado para trabajar con múltiples implementaciones.

Puedes:

  • tener varias clases implementando la misma interfaz;
  • registrarlas automáticamente;
  • etiquetarlas con tags;
  • inyectarlas como iterable;
  • resolver la implementación adecuada sin acoplarte a clases concretas.

Por ejemplo:

use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
final class DiscountResolver
{
    public function __construct(
        #[TaggedIterator('app.discount_strategy')]
        private iterable $strategies
    ) {
    }
}

Y en services.yaml:

services:
    App\Discount\Strategy\:
        resource: '../src/Discount/Strategy/*'
        tags: ['app.discount_strategy']

En Symfony esto sale bastante natural. No hace falta montar infraestructura rara ni inventarse mecanismos propios de descubrimiento de implementaciones.

Ese buen encaje con el framework es una de las razones por las que el patrón resulta especialmente cómodo en este ecosistema.

Casos de uso reales de Strategy Pattern en Symfony

Notificaciones por canal

Éste es uno de los casos más claros.

Tienes una misma intención de negocio: notificar algo. Pero la forma de hacerlo cambia según el canal:

  • email;
  • SMS;
  • push;
  • Slack;
  • webhook.

Aquí la implementación cambia bastante, las dependencias suelen ser distintas y la decisión normalmente se toma desde fuera del propio objeto. No estás modelando el comportamiento natural de una entidad, sino resolviendo una política de envío.

Ahí Strategy encaja bien.

interface NotificationStrategyInterface
{
    public function supports(string $channel): bool;
    public function send(Notification $notification): void;
}
final class EmailNotificationStrategy implements NotificationStrategyInterface
{
    public function __construct(
        private MailerInterface $mailer
    ) {
    }
    public function supports(string $channel): bool
    {
        return $channel === 'email';
    }
    public function send(Notification $notification): void
    {
        // envío por email
    }
}
final class SmsNotificationStrategy implements NotificationStrategyInterface
{
    public function __construct(
        private SmsClient $smsClient
    ) {
    }
    public function supports(string $channel): bool
    {
        return $channel === 'sms';
    }
    public function send(Notification $notification): void
    {
        // envío por SMS
    }
}
final class NotificationSender
{
    /**
     * @param iterable<NotificationStrategyInterface> $strategies
     */
    public function __construct(
        private iterable $strategies
    ) {
    }
    public function send(string $channel, Notification $notification): void
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($channel)) {
                $strategy->send($notification);
                return;
            }
        }
        throw new \RuntimeException(sprintf('Channel "%s" not supported', $channel));
    }
}

Integraciones con proveedores externos

Otro caso muy típico.

A nivel funcional quieres hacer “lo mismo”, pero el proveedor cambia:

  • pasarelas de pago;
  • transportistas externos;
  • CRMs;
  • sistemas de firma;
  • servicios de verificación;
  • proveedores de identidad.

En este tipo de escenarios, intentar centralizar toda la lógica suele llevar a una clase llena de ramas, clientes HTTP, adaptaciones de payload y tratamiento específico de errores.

Separarlo por estrategias suele ser bastante más razonable.

interface PaymentStrategyInterface
{
    public function supports(string $provider): bool;
    public function charge(PaymentRequest $request): PaymentResult;
}

Cada proveedor encapsula su comportamiento, sus dependencias y su mapping. El caso de uso o servicio de aplicación sólo resuelve cuál necesita.

Exportaciones en distintos formatos

Muy común en aplicaciones de gestión y backoffice.

La intención es la misma: exportar datos. Pero no es lo mismo exportar a:

  • CSV;
  • Excel;
  • JSON;
  • XML;
  • PDF.

Aquí la elección del comportamiento suele venir dada por contexto de aplicación, no por una responsabilidad natural de una entidad de dominio. Y cada formato puede requerir librerías, estructura y tratamiento propios.

interface ExportStrategyInterface
{
    public function supports(string $format): bool;
    public function export(array $data): string;
}

Cada formato en su clase, cada implementación aislada y un servicio principal mucho más limpio.

Políticas de negocio según contexto

Éste es uno de los usos más interesantes, aunque es menos vistoso.

Por ejemplo:

  • aplicar descuentos según reglas de negocio;
  • decidir una política de procesamiento según el origen;
  • ejecutar una validación distinta según la transición;
  • seleccionar una acción según el tipo de operación;
  • resolver una regla diferente según el canal o el entorno.

En estos casos lo importante es que la variación de comportamiento no pertenece claramente a una entidad concreta, sino a una política que la aplicación necesita resolver.

Ahí Strategy suele ser una buena forma de encapsular la lógica sin meter todo en el mismo servicio.

Ejemplo con descuentos

interface DiscountStrategyInterface
{
    public function supports(Order $order): bool;
    public function apply(Order $order): int;
}
final class PremiumDiscountStrategy implements DiscountStrategyInterface
{
    public function supports(Order $order): bool
    {
        return $order->customer()->isPremium();
    }
    public function apply(Order $order): int
    {
        return (int) round($order->subtotal() * 0.10);
    }
}
final class CampaignDiscountStrategy implements DiscountStrategyInterface
{
    public function supports(Order $order): bool
    {
        return $order->hasActiveCampaign();
    }
    public function apply(Order $order): int
    {
        return 500;
    }
}
final class DiscountResolver
{
    /**
     * @param iterable<DiscountStrategyInterface> $strategies
     */
    public function __construct(
        private iterable $strategies
    ) {
    }
    public function resolve(Order $order): int
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($order)) {
                return $strategy->apply($order);
            }
        }
        return 0;
    }
}

Cuándo no usar Strategy Pattern

Este punto es tan importante como el resto.

No todo necesita un Strategy. Y en algunos equipos acaba habiendo cierta tendencia a montar patrones antes de que exista el problema que justificaba usarlos.

Si tienes dos casos simples, estables, y la lógica es mínima, probablemente un match o incluso un if resuelven mejor el problema.

return match ($type) {
    'fixed' => 100,
    'percent' => 200,
    default => 0,
};

Esto no necesita una interfaz, tres implementaciones y un resolvedor. Necesita no complicar algo que todavía es simple.

Strategy empieza a compensar cuando se cumplen varias de estas condiciones:

  • la lógica de cada caso empieza a crecer;
  • cada variante necesita dependencias propias;
  • el número de casos puede seguir aumentando;
  • testear cada comportamiento por separado aporta valor;
  • tocar un caso obliga a reabrir siempre la misma clase;
  • la selección del comportamiento se hace desde un servicio de aplicación o desde una política externa.

Señales de que te puede venir bien en un proyecto Symfony

Hay síntomas bastante claros.

El servicio principal empieza a decidir demasiadas cosas

Si tu CheckoutService, NotificationService, ExportService o similar acumula demasiadas ramas “según el tipo”, normalmente hay una abstracción pendiente.

El constructor empieza a llenarse de dependencias que no siempre se usan

Cuando una sola clase necesita demasiados colaboradores porque soporta demasiadas variantes, casi siempre hay una distribución de responsabilidades mejorable.

Añadir un caso nuevo siempre implica tocar la misma clase delicada

Si cada nueva variante obliga a modificar un servicio central que ya mezcla demasiadas decisiones, estás generando un punto de fricción innecesario.

Probar una variante concreta es cada vez más molesto

Cuando para testear una rama concreta tienes que montar medio contexto porque la lógica está incrustada en una clase grande, probablemente esa lógica está mal colocada.

Ventajas reales del Strategy Pattern

Las ventajas no son teóricas. En proyectos reales se notan bastante cuando el patrón está bien aplicado.

La primera es mantenibilidad. Cada comportamiento vive en su propia clase y deja de engordar un servicio central.

La segunda es extensibilidad. Añadir una variante nueva suele consistir en crear una implementación nueva, no en reabrir una pieza especialmente sensible.

La tercera es testabilidad. Probar comportamientos aislados es bastante más cómodo que testear un servicio con múltiples ramas mezcladas.

La cuarta es claridad. Cuando alguien entra al proyecto, entender el sistema por variantes suele ser más fácil si cada una está encapsulada.

Y la quinta, en Symfony, es que encaja muy bien con el contenedor y el sistema de servicios.

Errores habituales al aplicarlo

Tampoco conviene idealizarlo.

Sobrediseñar demasiado pronto

Éste es el error más frecuente. Aplicar Strategy a algo que todavía es demasiado simple sólo mete ruido.

No controlar bien el criterio de selección

Si varias estrategias pueden responder true al mismo caso y no tienes una política clara, introduces ambigüedad.

En ese caso conviene decidir si debe haber una única estrategia válida, si debe existir prioridad o si realmente necesitas ejecutar varias.

Fragmentar sin mejorar realmente el diseño

Separar responsabilidades está bien. Pero si para seguir un flujo sencillo hay que saltar entre demasiadas clases pequeñas que apenas aportan nada, el coste cognitivo sube y el diseño empeora.

Usarlo para tapar un mal modelo

A veces el problema no es la ausencia de un patrón. Es que el dominio está mal modelado.

Y crear más servicios no arregla eso.

Testing: donde más suele compensar

Una de las partes donde mejor se nota este patrón es en tests.

Una estrategia concreta suele ser fácil de probar en aislamiento:

public function test_premium_discount_is_applied(): void
{
    $order = $this->createPremiumOrderWithSubtotal(10000);
    $strategy = new PremiumDiscountStrategy();
    self::assertTrue($strategy->supports($order));
    self::assertSame(1000, $strategy->apply($order));
}

Y el resolvedor también se puede testear de forma simple:

public function test_it_uses_the_first_supported_strategy(): void
{
    $strategy = $this->createMock(DiscountStrategyInterface::class);
    $strategy->method('supports')->willReturn(true);
    $strategy->method('apply')->willReturn(500);
    $resolver = new DiscountResolver([$strategy]);
    self::assertSame(500, $resolver->resolve($order));
}

Eso normalmente es bastante más cómodo que probar una clase central con múltiples ramas y demasiadas dependencias mezcladas.

Conclusión

El Strategy Pattern aporta valor cuando evita que una misma clase concentre demasiadas variantes de comportamiento y demasiadas decisiones de negocio.

En Symfony encaja especialmente bien porque el framework facilita mucho trabajar con múltiples implementaciones, tags e inyección de estrategias.

Pero precisamente por eso conviene usarlo con criterio.

No toda variación de comportamiento necesita un Strategy. Si el comportamiento pertenece al propio objeto, seguramente tiene más sentido empezar por polimorfismo. Si el problema sigue siendo simple, quizá un match sea suficiente.

Strategy merece la pena cuando la selección del comportamiento se decide desde fuera, depende del contexto, responde a una política de aplicación o necesita crecer sin convertir una clase central en un cuello de botella de mantenimiento.

En esos casos sí suele funcionar muy bien. Y en WATA Factory es un patrón que utilizamos precisamente en ese tipo de escenarios: cuando necesitamos desacoplar comportamientos, facilitar la evolución del sistema y mantener el código preparado para crecer sin perder claridad.

Porque, al final, la clave no está en aplicar patrones. Está en elegir soluciones que sigan teniendo sentido cuando el proyecto evoluciona.

Related Posts