Principios SOLID

Los principios SOLID son un conjunto de cinco principios de diseño de software que pueden ayudar a los desarrolladores a crear sistemas más robustos, escalables y mantenibles.

Vamos a verlos aplicados a PHP. Los principios SOLID son los siguientes:

Principio de Responsabilidad Única (SRP):

Este principio establece que una clase o módulo debe tener una única responsabilidad. Es decir, debe haber una sola razón por la cual una clase o módulo cambie. Si una clase o módulo tiene varias responsabilidades, es más difícil de mantener y cambiar.

Vamos a ver un ejemplo con PHP y Laravel:

class ClientController extends Controller {
    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
           'name' => 'required',
           'last_name' => 'required',
           'email' => 'required|email|unique:users',
       ]);

       if ($validator->fails()) {
            Session::flash('error', $validator->messages()->first());
            return redirect()->back()->withInput();
       }

       // create new client
       $client = Client::create([
           'name' => $request->first_name,
           'last_name' => $request->last_name,
           'email' => $request->email
       ]);

       return redirect()->route('login');
    }
}

El código anterior puede ser un código estándar para la petición en una web Laravel que emplea las propias plantillas Blade para guardar los datos de un cliente. El problema es que no está bien adaptado para posibles cambios a futuro. Vamos a ver porqué:

El código anterior está muy bien para usar únicamente las plantillas Blade de Laravel, como ya hemos comentado, pero... qué pasaría si se decide desacoplar el front con una SPA con Vue o incluso lanzar una versión app Android/iOS? Este código ya no nos sirve. El copiar/pegar este código y readaptarlo a la SPA o la app sería una opción pero probablemente sería la peor. La opción correcta sería extraer la lógica que gestiona los datos del cliente a otro archivo que se encargue exclusivamente de gestionar los cambios en el cliente, de esta forma, si se hacen cambios como añadir un campo al modelo de cliente simplemente se debe añadir en un sitio y no en 20 distintos con los riesgos que implica que se nos olvide alguno.

class ClientController extends Controller {
    public function store(Request $request)
    {
        $validator = Validator::make($request->all(), [
           'name' => 'required',
           'last_name' => 'required',
           'email' => 'required|email|unique:users'
       ]);

       if ($validator->fails()) {
            Session::flash('error', $validator->messages()->first());
            return redirect()->back()->withInput();
       }

       ClientHelper::createNewClient($request->all());
       return redirect()->route('login');
    }
}

Principio de Abierto/Cerrado (OCP)

Este principio establece que una clase debe estar abierta para su extensión pero cerrada para su modificación. En otras palabras, se debe poder agregar nuevas funcionalidades sin modificar el código existente.

Esto que puede sonar un poco confuso se podría resumir en algo como: el código que ya está escrito no debería ser necesario cambiarlo. Dicho así suena muy ideal pero lo que se trata es de acotar los cambios tanto como sea posible creando clases que se puedan extender pero que no se modifiquen para no romper lo que ya había.

Con 'extender' se entiende el reutilizar el código ya escrito ya sea a través de herencia o instanciación de la clase y llamar a sus métodos/funciones.

A todo esto no hay una solución única, se ha estudiado desde diferentes enfoques como patrones de diseño, arquitecturas, etc. pero vamos a ver una de estas técnicas más sencillas. Vamos a suponer que necesitamos convertir un texto markdown a HTML para poder mostrarlo luego, por ejemplo, en un blog (como este) o descargarlo como archivo. Supongamos que para ello necesitamos de un servicio y librería externa llamado FileMaker, que se encargará de la creación del archivo. Con todo esto, podríamos tener un código parecido al siguiente:

class MarkdownController extends Controller {
    public function generateFileDownload(Request $request) {
        $fileGenerator = new FileMaker();
        $fileGenerator->setContent($request->content); // Markdown format
        $textFile = $fileGenerator->generateFile('index.html');

        return response()->download($textFile, [
            'Content-Type' => 'application/html',
        ]);
    }
}

En principio en el ejemplo de código anterior podemos ver que se sigue correctamente el principio de responsabilidad única, el controlador no se encarga de nada relacionado al contenido de la respuesta, eso es cosa de la librería externa, de hecho, el controlador no sabe si está tratando con Markdown, Yaml o el formato que sea, se abstrae esa responsabilidad en un especialista como sería en este csao la librería FileMaker. Pero no es del todo correcta esta solución.

Como hemos comentado la responsabilidad se traslada a una librería de terceros como es FileMaker, dependemos de una API externa o desarrollo de otro desarrollador/organización externa a nuestro proyecto con lo que somos susceptibles de cambios de API, que se descontinue el desarrollo de la librería... en resumen infinidad de problemas que pueden derivar de no tener nosotros el control de dicha parte del código. Todo ello podría provar la necesidad de cambiar de librería por otra más actual, más segura, más rápida... lo que sea. Nos ponemos manos a la obra con su reemplazo y nos toca realizar búsquedas globales por todo el código para encontrar donde se está llamando a la librería. Esto aumenta la posibilidad de cometer errores y es una carga para el proyecto que más pronto o más tarde podría explotar.

¿Se podría solucionar esto de forma sencilla? Si, dependiendo de abastracciones/interfaces y no de las implementaciones en si mismas.

Vamos a ver:

Continuando con el ejemplo anterior podemos ver que se dependía de una librería externa FileMaker y que necesitamos, en su lugar, depender de un genérico que se encargue de tratar con los archivos.

En PHP disponemos tanto de Herencia como Interfaces para atajar el problema. En el caso de lidiar con el Markdown puede no ser la mejor idea tratar con clases base ya que pueden haber librerías muy diferentes a la hora de funcionar.

Habiendo comentado eso generamos una interfaz que especifique qué funciones necesitamos que haya cada vez que se gestionen archivos que contengan Markdown:

interface IFileGenerator {
    public function setup(); // API keys, basic config, etc.
    public function setContent($content);
    public function generateFile($fileName = null);
}

A través de la interfaz anterior especificamos qué requieren todas nuestras futuras clases que gestionen archivos con Markdown. Si la librería FileMaker no sigue esta interfaz es nuestra responsabilidad crear una clase que adapte su funcionamiento al requerido.

class CustomFileGenerator implements IFileGenerator {
    public function __construct() {
        $this->setup();
    }

    public function setup() {
        $this->generator = new FileMaker();
        $this->generator->api_key = env('REMOTE_API_KEY');
    }

    public function setContent($content) {
        $this->generator->setContent($content);
    }

    public function generateFile($fileName) {
        return $this->generator->generateFile($fileName);
    }
}

De esta forma cada vez que necesitemos un nuevo servicio para gestionar archivos con Markdown crearemos una clase contenedora que se base en la interfaz IFileGenerator.

Y con esto, usando Laravel donde disponemos del Contenedor de servicio podemos llamar a la clase que corresponda bindeándola a la interfaz de esta forma:

$this->app->bind('App\Interfaces\IFileGenerator', 'App\Services\Files\CustomFileGenerator');

Dicho todo lo anterior, podemos reescribir nuestra función en el controlador de la siguiente forma:

class MarkdownController extends Controller {
    public function generateFileDownload(Request $request, IFileGenerator $generator) {
        $generator->setContent($request->content);
        $textFile = $generator->generateFile('index.html');

        return response()->download($textFile, [
            'Content-Type' => 'application/html',
        ]);
    }
}

Analizando un poco la solución final vemos que el contenedor de servicios nos comparte el CustomFileGeneratormediante el IFileGenerator y a partir de aquí se abstrae su funcionamiento a la función generateFilede este retornando el archivo listo para devolver como descarga. En futuras modificaciones de la función el desarrollador que le toque realizar las modificaciones no tendrá que preocuparse de qué librería es la encargada de transformar a HTML el Markdown inicial.

Y por otra parte, en el caso de que se prescinda de la librería FileMaker simplemente se tendrá que crear otra implementación de la interfaz con otra librería y actualizar el binder a la nueva clase:

$this->app->bind('App\Interfaces\IFileGenerator', 'App\Services\PDF\NewCustomFileGenerator');

Principio de Sustitución de Liskov (LSP)

Este principio establece que las clases derivadas deben poder ser sustituidas por sus clases base sin cambiar el comportamiento del programa. En otras palabras, las subclases deben comportarse de la misma manera que sus superclases.

Un ejemplo para entender esto mejor sería viendo el caso del punto anterior donde se reemplaza un método de una clase genérica por el método de una clase específica el código debe seguir funcionando, la diferencia para la función que llama a uno u otro debería ser solo el nombre.

Principio de Segregación de la Interfaz (ISP)

Este principio establece que una interfaz debe ser específica para un cliente en particular. Es decir, una clase no debe ser forzada a depender de interfaces que no utiliza.

Cuantas más interfaces más especializaciones hay en la aplicación, más modularidad y por tanto menos extraño será el código.

Siguiendo con los ejemplos con PHP, con Laravel la mayoría de desarrolladores se encuentra en algún punto de su carrera con el llamado Patrón repositorio. Este patrón es muy útil en muchos casos pero si no se conoce bien su utilidad y donde puede tener más sentido utilizarlo se puede llegar a descartar.

En la mayoría de tutoriales sobre el Patrón repositorio se recomienda crear una interfaz común (repositorio) que definirá los métodos o funciones necesarios, algo como lo siguiente:

interface XRepository {
    public function find($id);
    public function all();
    public function store(array $data);
    public function update(array $data, $id);
    public function login(array $data, $id);
    public function delete($id);
}

Partiendo del modelo User habría que crear un UserRepository que implemente la XRepository y lo mismo con un Customer, un Provider... Esto nos puede dejar con funciones como el inicio de sesión donde no tiene sentido en modelos como el Customer o el Provider y solo tiene sentido en el User pero tenemos que generar dichas funciones de todos modos para cumplir con la interfaz o generar una excepción al llamarlos. Pero esto no sería un buen uso de dicho patrón.

La solución es generar interfaces más simples y especializadas en lo que se necesita en cada caso. Siguiendo el ejemplo anterior se podría generar una interfaz NoLoggableRepository para los casos que no se necesita iniciar sesión y un LoggableRepository para aquellos modelos como el Userque si requieran de un inicio de sesión y todo lo que ello conlleva.

Principio de Inversión de Dependencia (DIP)

Este principio establece que los módulos de alto nivel no deben depender de los módulos de bajo nivel, sino que ambos deben depender de abstracciones. En otras palabras, las dependencias deben ser invertidas, de modo que los módulos de bajo nivel dependan de los de alto nivel.

Si tenemos una clase de alto nivel (usa otras clases más pequeñas y especializadas) no debería depender de una clase de nivel bajo en concreto sino que debería depender de abstracciones como pueden ser; clases base, interfaces, etc.

Como hemos visto antes, en el caso de que la empresa decida cambiar un determinado servicio por otro dentro de un proyecto puede ser una tarea hercúlea si no se ha planificado con antelación su mantenimiento. La forma correcta sería crear una interfaz para un determinado servicio como la gestión del markdown y ya la clase o clases que tengan que ofrecer ese servicio se encargarán de cumplir con los requisitos de la interfaz.

f3rran - 2024