Desbloqueando la flexibilidad: tutorial del patrón Estrategia en JavaScript

En el vasto universo del desarrollo de software, la constante búsqueda de código que sea a la vez robusto, mantenible y escalable es una quimera. Nos encontramos a menudo construyendo sistemas que, con el tiempo, se vuelven complejos y difíciles de modificar a medida que las funcionalidades evolucionan o las reglas de negocio cambian. Este es un desafío universal, y es precisamente aquí donde los patrones de diseño emergen como faros de sabiduría, ofreciendo soluciones probadas para problemas recurrentes. No son meras plantillas; son guías conceptuales que nos permiten estructurar nuestro código de una manera que promueve la flexibilidad, la reutilización y la claridad. Hoy, nos adentraremos en uno de esos patrones fundamentales: el patrón Estrategia. A través de este tutorial, exploraremos no solo su definición y beneficios, sino que también desglosaremos su implementación práctica en JavaScript, acompañado de ejemplos de código claros que te permitirán integrar esta poderosa herramienta en tus propios proyectos. Si alguna vez te has enfrentado a un código plagado de sentencias `if-else` anidadas o `switch` gigantescos que gestionan diferentes algoritmos, prepararte para descubrir una alternativa más elegante y mantenible.

¿Qué son los patrones de diseño y por qué son importantes?

Close-up of vibrant colored JavaScript code showing functions and syntax on a dark screen.

Antes de sumergirnos en el patrón Estrategia, es crucial establecer una base sólida sobre qué son los patrones de diseño en general. En pocas palabras, un patrón de diseño es una solución general y reutilizable a un problema que ocurre comúnmente dentro de un contexto de diseño dado en el desarrollo de software. No es una solución final o una pieza de código específica que se pueda copiar y pegar directamente, sino una descripción o plantilla de cómo resolver un problema que se puede usar en muchas situaciones diferentes. Son el resultado de años de experiencia colectiva de desarrolladores y arquitectos, destilados en lecciones que podemos aplicar hoy.

Su importancia radica en varios pilares fundamentales:

  • Un lenguaje común: Proporcionan un vocabulario estándar para que los desarrolladores se comuniquen sobre soluciones de diseño. Decir "vamos a aplicar un patrón Observador" es mucho más conciso y claro que describir toda la arquitectura de publicación/suscripción.
  • Soluciones probadas: Al ser soluciones a problemas recurrentes, han sido refinadas y probadas a lo largo del tiempo, reduciendo el riesgo de errores comunes y malas prácticas.
  • Reusabilidad: Fomentan el desarrollo de código más modular y reutilizable, lo que a su vez acelera el desarrollo y mejora la calidad.
  • Mantenibilidad y escalabilidad: Los sistemas diseñados con patrones suelen ser más fáciles de entender, mantener y extender, ya que las responsabilidades están mejor separadas y las dependencias son más gestionables.
  • Mejores prácticas: Nos guían hacia el cumplimiento de principios de diseño de software como SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), DRY (Don't Repeat Yourself) y KISS (Keep It Simple, Stupid), lo cual es, a mi parecer, uno de los mayores beneficios a largo plazo, ya que nos forma como mejores ingenieros.

Si deseas profundizar más en el concepto general de los patrones de diseño, te recomiendo consultar el artículo en Wikipedia sobre Patrones de Diseño.

Entendiendo el patrón Estrategia

Definición y propósito

El patrón Estrategia es un patrón de diseño de comportamiento que nos permite definir una familia de algoritmos, encapsular cada uno de ellos y hacerlos intercambiables. La Estrategia permite que el algoritmo varíe independientemente de los clientes que lo utilizan. Es decir, en lugar de implementar directamente un algoritmo en una clase, se delega la responsabilidad a un objeto separado llamado "estrategia". Este objeto de estrategia puede ser cambiado en tiempo de ejecución, lo que permite al cliente modificar el comportamiento de un objeto sin alterar su estructura interna. Imagina que tienes varias formas de realizar una misma acción (por ejemplo, ordenar una lista, calcular un precio, validar un formulario), y quieres poder cambiar esa forma fácilmente sin tener que reescribir la lógica principal.

El problema que resuelve

El problema principal que el patrón Estrategia aborda es la proliferación de sentencias condicionales (como `if/else if/else` o `switch`) para seleccionar diferentes comportamientos o algoritmos. Sin este patrón, una clase podría terminar conteniendo una gran cantidad de lógica condicional para manejar todas las posibles variantes de una operación. Esto tiene varias desventajas:

  • Falta de cohesión: La clase se vuelve responsable de demasiadas cosas: su lógica principal y la lógica para seleccionar y ejecutar varios algoritmos.
  • Violación del principio Abierto/Cerrado (OCP): Cada vez que necesitemos añadir un nuevo algoritmo, tendremos que modificar la clase existente, lo que va en contra del principio de que las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para la extensión, pero cerradas para la modificación. Puedes leer más sobre el principio Abierto/Cerrado en esta guía de Refactoring Guru.
  • Dificultad de prueba: Probar una clase con mucha lógica condicional puede ser complicado, ya que cada rama necesita ser probada exhaustivamente.
  • Redundancia y complejidad: El código se vuelve más grande, más difícil de leer y propenso a errores.

Componentes clave del patrón

El patrón Estrategia se compone principalmente de tres elementos:

  • Contexto (Context): Es la clase que mantiene una referencia a un objeto Estrategia. El Contexto no sabe cómo ejecutar la estrategia; solo sabe que la estrategia tiene un método común que puede invocar para realizar la tarea. Los clientes interactúan solo con el Contexto.
  • Interfaz de Estrategia (Strategy Interface): Define una interfaz común para todos los algoritmos soportados. El Contexto utiliza esta interfaz para llamar al algoritmo definido por una Estrategia Concreta. En JavaScript, esto suele ser una función, un objeto con un método específico, o una clase base con un método abstracto (que se implementa en clases derivadas).
  • Estrategias Concretas (Concrete Strategies): Son las implementaciones de los algoritmos definidos por la interfaz de Estrategia. Cada Estrategia Concreta implementa un comportamiento específico.

La clave aquí es que el Contexto delega la ejecución del algoritmo a uno de los objetos Estrategia Concreta que tiene asignado. Este desacoplamiento permite que los algoritmos sean cambiados o extendidos sin modificar el Contexto.

Implementación del patrón Estrategia en JavaScript

JavaScript, con su naturaleza flexible y su soporte para paradigmas de programación tanto orientados a objetos como funcionales, ofrece varias formas de implementar el patrón Estrategia. A continuación, veremos dos enfoques comunes: usando objetos y funciones, y usando clases ES6.

Con objetos y funciones (un enfoque más funcional)

Este enfoque es muy natural en JavaScript, donde las funciones son ciudadanos de primera clase. Podemos definir nuestras estrategias como funciones o como métodos dentro de objetos literales, y luego un objeto Contexto simplemente las invocará.


// 1. Estrategias Concretas (como funciones)
const ordenarBurbuja = (arr) => {
    console.log("Ordenando con el algoritmo de burbuja...");
    const len = arr.length;
    for (let i = 0; i < len; i++) {
        for (let j = 0; j < len - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // Intercambiar elementos
                [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
            }
        }
    }
    return arr;
};

const ordenarQuickSort = (arr) => {
    console.log("Ordenando con QuickSort...");
    if (arr.length <= 1) {
        return arr;
    }
    const pivot = arr[arr.length - 1];
    const left = [];
    const right = [];
    for (let i = 0; i < arr.length - 1; i++) {
        if (arr[i] < pivot) {
            left.push(arr[i]);
        } else {
            right.push(arr[i]);
        }
    }
    return [...ordenarQuickSort(left), pivot, ...ordenarQuickSort(right)];
};

// 2. Interfaz de Estrategia (implícita: todas tienen un método 'sort' o son funciones)
// Aquí, simplemente esperamos que las estrategias sean funciones que toman un array y lo ordenan.

// 3. Contexto
const Ordenador = (estrategia) => {
    let estrategiaActual = estrategia;

    return {
        // Establece una nueva estrategia en tiempo de ejecución
        setEstrategia: (nuevaEstrategia) => {
            estrategiaActual = nuevaEstrategia;
        },
        // Ejecuta la estrategia actual
        ordenar: (arrayParaOrdenar) => {
            if (typeof estrategiaActual === 'function') {
                return estrategiaActual(arrayParaOrdenar);
            }
            throw new Error("La estrategia actual no es una función válida.");
        }
    };
};

// Demostración de uso
const misNumeros = [5, 2, 9, 1, 7];

// Usando Bubble Sort
const ordenadorBurbuja = Ordenador(ordenarBurbuja);
console.log(`Original: ${misNumeros}`);
console.log(`Ordenado con burbuja: ${ordenadorBurbuja.ordenar([...misNumeros])}`); // Usamos spread para no modificar el original

// Cambiando a QuickSort en tiempo de ejecución
ordenadorBurbuja.setEstrategia(ordenarQuickSort);
console.log(`Ordenado con QuickSort: ${ordenadorBurbuja.ordenar([...misNumeros])}`);

// Una nueva instancia de ordenador con QuickSort directamente
const ordenadorQuick = Ordenador(ordenarQuickSort);
console.log(`Ordenado con QuickSort (nueva instancia): ${ordenadorQuick.ordenar([...misNumeros])}`);

En este ejemplo, `ordenarBurbuja` y `ordenarQuickSort` son nuestras Estrategias Concretas. El `Ordenador` es el Contexto, que recibe una estrategia y puede cambiarla dinámicamente. La interfaz es implícita: cualquier función que tome un array y lo devuelva ordenado. Me parece que este enfoque es particularmente potente en JavaScript por su simplicidad y la capacidad de tratar funciones como valores de primera clase, lo que reduce la verbosidad.

Con clases (ES6)

Para quienes provienen de lenguajes orientados a objetos o prefieren una estructura más formal, las clases ES6 son una excelente opción. Este enfoque se alinea más con la visión clásica del patrón Estrategia, donde la "interfaz" se representa por un método común que todas las clases de estrategia deben implementar.


// 1. Interfaz de Estrategia (representada por una clase abstracta o un contrato implícito)
// En JavaScript, no hay interfaces ni clases abstractas nativas.
// Simulamos la interfaz definiendo un método común que todas las estrategias deben tener.
class EstrategiaOrdenacion {
    ejecutar(array) {
        throw new Error("El método 'ejecutar' debe ser implementado por las estrategias concretas.");
    }
}

// 2. Estrategias Concretas
class OrdenacionBurbuja extends EstrategiaOrdenacion {
    ejecutar(arr) {
        console.log("Ordenando con el algoritmo de burbuja (clase)...");
        const len = arr.length;
        for (let i = 0; i < len; i++) {
            for (let j = 0; j < len - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
                }
            }
        }
        return arr;
    }
}

class OrdenacionQuickSort extends EstrategiaOrdenacion {
    ejecutar(arr) {
        console.log("Ordenando con QuickSort (clase)...");
        if (arr.length <= 1) {
            return arr;
        }
        const pivot = arr[arr.length - 1];
        const left = [];
        const right = [];
        for (let i = 0; i < arr.length - 1; i++) {
            if (arr[i] < pivot) {
                left.push(arr[i]);
            } else {
                right.push(arr[i]);
            }
        }
        return [...new OrdenacionQuickSort().ejecutar(left), pivot, ...new OrdenacionQuickSort().ejecutar(right)];
    }
}

// 3. Contexto
class ContextoOrdenacion {
    constructor(estrategia) {
        this.estrategia = estrategia;
    }

    setEstrategia(nuevaEstrategia) {
        this.estrategia = nuevaEstrategia;
    }

    ejecutarOrdenacion(array) {
        if (!this.estrategia || !(this.estrategia instanceof EstrategiaOrdenacion)) {
            throw new Error("No se ha establecido una estrategia de ordenación válida.");
        }
        return this.estrategia.ejecutar(array);
    }
}

// Demostración de uso con clases
const otrosNumeros = [10, 4, 8, 3, 6];

// Instanciamos el contexto con una estrategia inicial
const contextoConBurbuja = new ContextoOrdenacion(new OrdenacionBurbuja());
console.log(`Original: ${otrosNumeros}`);
console.log(`Ordenado con burbuja: ${contextoConBurbuja.ejecutarOrdenacion([...otrosNumeros])}`);

// Cambiamos la estrategia en tiempo de ejecución
contextoConBurbuja.setEstrategia(new OrdenacionQuickSort());
console.log(`Ordenado con QuickSort: ${contextoConBurbuja.ejecutarOrdenacion([...otrosNumeros])}`);

// Creamos otra instancia de contexto con una estrategia diferente desde el principio
const contextoConQuick = new ContextoOrdenacion(new OrdenacionQuickSort());
console.log(`Ordenado con QuickSort (nueva instancia): ${contextoConQuick.ejecutarOrdenacion([...otrosNumeros])}`);

Aquí, la clase `EstrategiaOrdenacion` sirve como la "interfaz" o "clase base" abstracta. Aunque JavaScript no tiene una implementación nativa para clases abstractas, podemos simularla lanzando un error si el método `ejecutar` no es sobrescrito, lo cual es una buena práctica para asegurar que los desarrolladores que implementen nuevas estrategias sigan el contrato. Para más detalles sobre las clases en JavaScript, puedes consultar la documentación de MDN sobre Clases.

Caso práctico: cálculo de descuentos en una tienda online

Para ilustrar la utilidad del patrón Estrategia, consideremos un escenario común en el desarrollo web: una tienda online que necesita aplicar diferentes tipos de descuentos a los productos en un carrito de compra. Los descuentos pueden variar según promociones, códigos de cupón, programas de fidelidad, etc. Sin el patrón Estrategia, un enfoque ingenuo podría llevarnos a un código como este:


// Pseudocódigo de un enfoque sin el patrón Estrategia
function calcularPrecioFinal(precioBase, tipoDescuento, valorDescuento) {
    let precioConDescuento = precioBase;
    if (tipoDescuento === 'porcentaje') {
        precioConDescuento -= precioBase * (valorDescuento / 100);
    } else if (tipoDescuento === 'fijo') {
        precioConDescuento -= valorDescuento;
    } else if (tipoDescuento === 'envio_gratis') {
        // Asumimos que el "valorDescuento" aquí es el coste del envío
        precioConDescuento -= valorDescuento; // O simplemente no se suma coste de envío
    }
    // ... y si añadimos más tipos de descuento, esta función crece infinitamente.
    return precioConDescuento;
}

Este enfoque, aunque funcional al principio, se vuelve rápidamente inmanejable. Cada nuevo tipo de descuento requiere modificar la función `calcularPrecioFinal`, violando el OCP y aumentando la complejidad. Aquí es donde el patrón Estrategia brilla.

Diseño de las estrategias de descuento

Definiremos una interfaz común para todas las estrategias de descuento, que será simplemente un método `aplicarDescuento` que tomará el precio original y el valor del descuento, y devolverá el precio final. Luego, crearemos estrategias concretas para cada tipo de descuento.

Implementación de las estrategias concretas (código)


        
Diario Tecnología