Patrón estrategia en PHP

El patrón estrategia (strategy pattern en inglés) es un patrón de diseño que permite intercambiar algoritmos en un proceso según la estrategia que deseemos. Pero pongamos un ejemplo para ilustrarlo.

En nuestra aplicación de facturación, nos solicitan poder exportar a texto un listado de clientes con una facturación mayor al importe introducido por el usuario. Parece un trabajo sencillo y nos ponemos manos a la obra, crearemos un servicio que haga el cálculo y exporte los datos.

<?php
class ExportCustomers
{
    private CustomerRepository $customerRepository;

    public function __construct(CustomerRepository $customerRepository)
    {
        $this->customerRepository = $customerRepository;
    }

    public function __invoke(string $filename, float $amount): void
    {
        $lines = array_map(
          fn(Customer $customer) => $customer->getName() . " " . $customer->getLastName() . " " . $customer->getAmount(),
          $this->customerRepository->findByAmount($amount)
        );

        $fp = fopen($filename, "w+");
        fwrite($fp, implode("\n", $lines));
        fclose($fp);
    }
}

Y lo invocaríamos así:

$service = new ExportCustomers($repository);
$service("clientes.txt", 100);

Qué fácil, esta gente de negocio no sabe con quién está tratando…

Al día siguiente, el departamento de administración, supercontento con su nuevo listado, se da cuenta que el listado en texto funciona bien para imprimirlo, pero les gustaría poder importarlo en una hoja de cálculo para poder tratar esos datos. Por supuesto quieren que se siga pudiendo exportar a texto también.

En este momento, nos damos cuenta de que nuestra implementación no es muy flexible, y tendríamos que crear un nuevo servicio que formatease y exportase los clientes según el formato dado. En este ejemplo sencillo no es muy preocupante, pues no hay apenas lógica fuera del formateo y exportación, pero si tuviesemos más lógica, por ejemplo guardar un log de que se ha exportado el fichero, y algunos cálculos, tendríamos que implementar cada vez estos.

Para solventar este problema, antes de hacer el cambio que nos han pedido, vamos a refactorizar este servicio para que sea más extensible y respete el principio Open/Close de los principios SOLID, es decir, que esté abierto a extensión sin modificar la clase.

Lo que haremos será delegar la tarea de formatear y escribir el fichero, a un servicio especializado en esa tarea, y para ello lo primero que haremos será definir la interface de ese servicio.

<?php

namespace App\Service;

interface Writer
{
    public function write(array $customers, string $filename): void;
}

De este modo, ya tenemos la interface que tendrá el servicio, y podemos modificar nuestro caso de uso. Poco importa que no esté implementado por ahora, el caso de uso debe empezar a hacer uso de la interface y delegarle estas responsabilidades de escribir el fichero. Así que vamos a inyectarlo por constructor, y a hacer uso de él.

class ExportCustomers
{
    private CustomerRepository $customerRepository;
    private Writer $writer;

    public function __construct(CustomerRepository $customerRepository, Writer $writer)
    {
        $this->customerRepository = $customerRepository;
        $this->writer = $writer;
    }

    public function __invoke(string $filename, float $amount): void
    {
        $customers = $this->customerRepository->findByAMount($amount);
        $this->writer->write($customers, $filename);
    }
}

Fíjate que ahora, nuestro servicio de exportación sólo se dedica a recoger los clientes a traves del repositorio y pasárselos a nuestro servicio encargado de la escritura. De modo que ya podemos hacer una implementación de de nuestro writer para texto.

class TxtWriter implements Writer
{
    public function write(array $customers, string $filename): void
    {
        $lines = array_map(
            fn(Customer $customer) => $customer->getName() . " " . $customer->getLastName() . " " . $customer->getAmount(),
            $customers
        );

        $fp = fopen($filename, "w+");
        fwrite($fp, implode("\n", $lines));
        fclose($fp);
    }
}

Y lo invocaríamos así:

$writer = new TxtWriter();
$service = new ExportCustomers($repository, $writer);
$service("clientes.txt", 100);

Ahora, el writer, será una estrategia de exportación (de ahí el nombre del patrón), en nuestro caso tenemos implementada la estrategia de exportar a un fichero de texto, pero podemos crear fácilmente una estrategia que exporte el fichero en formato csv, excel, pdf, word… Implementemos la petición de los usuarios y exportemos a csv el fichero para que puedan importarlo en una hoja de cálculo.

class CsvWriter implements Writer
{
    public function write(array $customers, string $filename): void
    {
        $lines = array_map(
            fn(Customer $customer) => $customer->getName() . ";" . $customer->getLastName() . ";" . $customer->getAmount(),
            $customers
        );

        $fp = fopen($filename, "w+");
        fwrite($fp, implode("\n", $lines));
        fclose($fp);
    }
}

Y lo invocaríamos así:

$writer = new CsvWriter();
$service = new ExportCustomers($repository, $writer);
$service("clientes.txt", 100);

De este modo, se escoge la estrategia de exportación sin afectar a la lógica del resto del proceso, permitiendo crear tantas estrategias como necesites sin afectar al resto.

Podemos escoger la estrategia en tiempo de compilación, creando un caso de uso para cada uno de los formatos, o si necesitamos seleccionar la estrategia en tiempo de ejecución, por ejemplo porque depende del nombre del fichero que ponga el usuario en cada momento, podemos utilizar una factoría que nos devuelva una instancia del writer concreto de esa ejecución.

class WriterFactory
{
    public static function getWriter(string $filename): Writer
    {
        $fileNameParts = explode(".", $filename);
        $extension = $fileNameParts[count($fileNameParts) - 1];

        switch ($extension) {
            case 'txt':
                return new TxtWriter();
            case 'csv':
                return new CsvWriter();
            default:
                throw new \Exception('Writer not implemented for ' . $extension . ' files');
        }
    }
}

Y lo invocaríamos así, de modo que dependiendo del nombre del fichero, se instanciará una implementación del writer u otra.

$filename = "clientes.txt";
$writer = WriterFactory::getWriter($filename);
$service = new ExportCustomers($repository, $writer);
$service($filename, 100);