Implementando el Patrón Strategy de Forma Dinámica en C#

El Patrón de Diseño Strategy es uno de los patrones de comportamiento más fundamentales y útiles. Nos permite definir una familia de algoritmos, encapsular cada uno de ellos y hacerlos intercambiables. En esencia, permite que el algoritmo varíe independientemente de los clientes que lo utilizan.

A menudo, se introduce este patrón como una solución elegante al Principio de Abierto/Cerrado (OCP) de SOLID. Sin embargo, muchas implementaciones básicas se detienen a mitad de camino, dejando al cliente la responsabilidad de instanciar la estrategia correcta.

En este post, vamos a llevar el patrón Strategy al siguiente nivel. Partiremos de un ejemplo clásico y lo evolucionaremos hacia una solución dinámica y auto-configurable usando reflexión, haciendo nuestro sistema verdaderamente extensible y robusto.

El Escenario: Cálculo de Costos de Envío

Imaginemos que estamos construyendo un sistema de comercio electrónico. Una de las funcionalidades clave es calcular el costo de envío de un pedido. Los métodos de envío y sus costos pueden cambiar y expandirse con el tiempo.

El Enfoque Problemático: Una Larga Cadena de if-else

Un enfoque inicial y muy común sería tener una única clase ShippingCalculator con un método que contenga una lógica condicional para cada tipo de envío.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Order { /* ... */ }

public class ShippingCalculator
{
// ¡Esto viola el Principio de Abierto/Cerrado!
public decimal Calculate(Order order, string shippingType)
{
if (shippingType == "Standard")
{
return 10m;
}
else if (shippingType == "Express")
{
return 25m;
}
else if (shippingType == "NextDayAir")
{
return 50m;
}
// Cada nuevo método de envío requiere modificar esta clase.
// ¡Esto es frágil y difícil de mantener!
return 0;
}
}

Este diseño es un claro ejemplo de una violación del Principio de Abierto/Cerrado. La clase está “abierta para modificación”, lo que significa que cada vez que el negocio introduce un nuevo método de envío, un desarrollador debe entrar y cambiar esta clase, arriesgándose a introducir errores en la lógica existente.

La Solución Clásica: El Patrón Strategy

El patrón Strategy nos ofrece una salida elegante. La idea es extraer cada algoritmo de cálculo en su propia clase, todas implementando una interfaz común.

Paso 1: Definir la Interfaz de la Estrategia

Primero, definimos un contrato que todas nuestras estrategias de cálculo de envío deben seguir.

1
2
3
4
public interface IShippingStrategy
{
decimal Calculate(Order order);
}

Paso 2: Crear las Estrategias Concretas

A continuación, creamos una clase separada para cada método de envío.

1
2
3
4
5
6
7
8
9
public class StandardShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 10m;
}

public class ExpressShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 25m;
}

Paso 3: Usar las Estrategias

Ahora, nuestra clase ShippingCalculator ya no contiene la lógica condicional. En su lugar, recibe un objeto de estrategia y lo utiliza.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ShippingCalculator
{
public decimal Calculate(Order order, IShippingStrategy strategy)
{
return strategy.Calculate(order);
}
}

// Uso:
var order = new Order();
var calculator = new ShippingCalculator();

// El cliente es responsable de elegir e instanciar la estrategia correcta.
var standardCost = calculator.Calculate(order, new StandardShipping()); // 10m
var expressCost = calculator.Calculate(order, new ExpressShipping()); // 25m

¡Genial! Hemos cumplido con el Principio de Abierto/Cerrado. Para agregar un nuevo método de envío, simplemente creamos una nueva clase que implemente IShippingStrategy. La clase ShippingCalculator no necesita ser modificada.

Pero… ¿hemos resuelto el problema por completo? No del todo. Hemos trasladado la responsabilidad de la selección de la estrategia al código cliente. El cliente ahora necesita saber sobre todas las clases de estrategia concretas y contener la lógica para decidir cuál instanciar.

La Solución Avanzada: Una Fábrica de Estrategias Dinámica

Para que nuestro sistema sea verdaderamente desacoplado y extensible, necesitamos un mecanismo que pueda seleccionar la estrategia correcta de forma automática, basándose en algún dato (como un string que identifique el método de envío).

Aquí es donde entra en juego el Patrón Factory, potenciado con un poco de reflexión de C#.

Paso 1: Identificar las Estrategias con Atributos

Para que nuestra fábrica pueda reconocer las estrategias, las marcaremos con un atributo personalizado que contenga su nombre único.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Un atributo para decorar nuestras clases de estrategia.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class ShippingMethodAttribute : Attribute
{
public string Name { get; }

public ShippingMethodAttribute(string name)
{
Name = name;
}
}

// Aplicamos el atributo a nuestras clases.
[ShippingMethod("Standard")]
public class StandardShipping : IShippingStrategy { /* ... */ }

[ShippingMethod("Express")]
public class ExpressShipping : IShippingStrategy { /* ... */ }

Paso 2: Construir la Fábrica Dinámica

Esta fábrica será la pieza central de nuestra solución. Al iniciarse, escaneará nuestro ensamblado en busca de clases que implementen IShippingStrategy y tengan nuestro atributo. Luego, las registrará en un diccionario para un acceso rápido y eficiente.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System.Reflection;

public class ShippingStrategyFactory
{
private readonly Dictionary<string, IShippingStrategy> _strategies;

public ShippingStrategyFactory()
{
_strategies = new Dictionary<string, IShippingStrategy>(StringComparer.OrdinalIgnoreCase);

// Usamos reflexión para encontrar y registrar todas las estrategias disponibles.
var strategyTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => typeof(IShippingStrategy).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);

foreach (var type in strategyTypes)
{
var attr = type.GetCustomAttribute<ShippingMethodAttribute>();
if (attr != null)
{
var strategyInstance = (IShippingStrategy)Activator.CreateInstance(type);
_strategies.Add(attr.Name, strategyInstance);
}
}
}

public IShippingStrategy GetStrategy(string shippingMethodName)
{
if (_strategies.TryGetValue(shippingMethodName, out var strategy))
{
return strategy;
}

throw new NotSupportedException($"El método de envío '{shippingMethodName}' no es soportado.");
}
}

Nota: En una aplicación real con Inyección de Dependencias, esta fábrica se registraría como un servicio Singleton para que el escaneo solo ocurra una vez al inicio de la aplicación.

Paso 3: Refinar el ShippingCalculator y el Order

Ahora, el ShippingCalculator usará la fábrica para obtener la estrategia correcta, basándose en una propiedad del objeto Order.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Order
{
public string ShippingType { get; set; }
// ... otras propiedades
}

public class ShippingCalculator
{
private readonly ShippingStrategyFactory _strategyFactory;

public ShippingCalculator(ShippingStrategyFactory strategyFactory)
{
_strategyFactory = strategyFactory;
}

public decimal Calculate(Order order)
{
var strategy = _strategyFactory.GetStrategy(order.ShippingType);
return strategy.Calculate(order);
}
}

El Resultado: Un Sistema Verdaderamente Extensible

Veamos cómo se ve el uso final. El código cliente es ahora limpio, declarativo y completamente ajeno a las implementaciones concretas de las estrategias.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 1. Inicializamos nuestra fábrica (esto se haría una vez al inicio de la app).
var factory = new ShippingStrategyFactory();
var calculator = new ShippingCalculator(factory);

// 2. El cliente simplemente crea un pedido y pide el cálculo.
var order1 = new Order { ShippingType = "Standard" };
var standardCost = calculator.Calculate(order1);
Console.WriteLine($"Costo de envío estándar: {standardCost}"); // 10m

var order2 = new Order { ShippingType = "Express" };
var expressCost = calculator.Calculate(order2);
Console.WriteLine($"Costo de envío express: {expressCost}"); // 25m

// --- LA PRUEBA DE FUEGO ---
// El negocio introduce un nuevo método de envío: "Drone Delivery".
// El desarrollador solo necesita hacer una cosa: crear una nueva clase.

[ShippingMethod("Drone")]
public class DroneDeliveryShipping : IShippingStrategy
{
public decimal Calculate(Order order) => 100m;
}

// ¡Y ya está! No se modifica ningún otro código.
// La próxima vez que se inicie la aplicación, la fábrica lo descubrirá.
var order3 = new Order { ShippingType = "Drone" };
var droneCost = calculator.Calculate(order3);
Console.WriteLine($"Costo de envío con drone: {droneCost}"); // 100m

Conclusión

Al combinar el Patrón Strategy con un Patrón Factory dinámico, hemos creado un sistema que no solo cumple con el Principio de Abierto/Cerrado, sino que lo hace de una manera elegante y automatizada.

  • Cerrado para Modificación: Las clases ShippingCalculator y ShippingStrategyFactory son estables y no necesitan cambios.
  • Abierto para Extensión: Agregar nueva funcionalidad es tan simple como crear una nueva clase.

Este enfoque avanzado del patrón Strategy es una herramienta poderosa para construir software flexible, mantenible y que puede evolucionar con las necesidades del negocio sin desmoronarse.