Los servicios no necesitan nombres

hace 4 años por Jiří Pudil  

Me gusta la solución de Nette Framework para la inyección de dependencias. Realmente la amo. Este artículo está aquí para compartir esta pasión y explicar por qué creo que es la mejor solución DI en el ecosistema PHP actual.

(Este post fue publicado originalmente en el blog del autor.)

El desarrollo de software es un proceso iterativo infinito de abstracción. Encontramos abstracciones adecuadas del mundo real en el modelado de dominio. En la programación orientada a objetos, usamos abstracciones para describir y hacer cumplir contratos entre diferentes actores en el sistema. Introducimos nuevas clases en el sistema para encapsular responsabilidades y definir sus límites, y luego usamos la composición para crear todo el sistema.

Hablo de la necesidad de extraer la lógica de autenticación del siguiente controlador:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        if ($username !== 'admin' || $password !== 'p4ssw0rd!') {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Probablemente reconocerá que la verificación de credenciales no pertenece allí. El controlador no tiene la responsabilidad de determinar qué credenciales son válidas; según el principio de responsabilidad única, el controlador solo debería tener una única razón para cambiar, y esta razón debería estar dentro de la interfaz de usuario de la aplicación, no en el proceso de autenticación.

Tomemos el camino obvio y extraigamos la condición a una clase Authenticator:

final class Authenticator
{
    public function authenticate(string $username, string $password): bool
    {
        return $username === 'admin' && $password === 'p4ssw0rd!';
    }
}

Ahora basta con delegar desde el controlador a este autenticador. Hemos creado el autenticador como una dependencia del controlador, y el controlador de repente necesita obtenerlo de alguna parte:

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== fácil, ¡simplemente creo uno nuevo!
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Esta forma ingenua funcionará. Pero solo hasta que se implemente una autenticación más robusta, que requerirá que el autenticador consulte una tabla de usuarios en la base de datos. De repente, Authenticator tiene su propia dependencia, digamos UserRepository, que a su vez depende de una instancia de Connection, que depende de parámetros del entorno específico. ¡Esto escaló rápidamente!

Crear instancias manualmente en todas partes no es una forma sostenible de gestionar dependencias. Por eso tenemos el patrón de inyección de dependencias, que permite al controlador simplemente declarar la dependencia de Authenticator, y dejar que alguien más proporcione realmente la instancia. Y ese alguien más se llama contenedor de inyección de dependencias.

El contenedor de inyección de dependencias es el arquitecto principal de la aplicación: sabe cómo resolver las dependencias de cualquier servicio en el sistema y es responsable de crearlos. Los contenedores DI son tan comunes hoy en día que casi todos los frameworks web importantes tienen su propia implementación de contenedor, e incluso existen paquetes independientes dedicados a la inyección de dependencias, por ejemplo, PHP-DI.

Quemando la pimienta

La cantidad de opciones finalmente motivó a un grupo de desarrolladores a buscar una abstracción que las hiciera interoperables. Una interfaz común fue pulida con el tiempo y finalmente propuesta para PHP-FIG en la siguiente forma:

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

Esta interfaz ilustra una propiedad muy importante de los contenedores DI: **Son un buen sirviente, pero fácilmente pueden convertirse en un mal amo. Son inmensamente útiles si sabe cómo usarlos, pero si los usa incorrectamente, se quemará. Considere el siguiente contenedor:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories['authenticator'] = fn() => new Authenticator($this->get('userRepository'));
        $this->factories['userRepository'] = fn() => new UserRepository($this->get('connection'));
        $this->factories['connection'] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Hasta ahora, todo bien. La implementación parece buena según los estándares que hemos establecido: realmente sabe cómo crear cada servicio en la aplicación y resuelve recursivamente sus dependencias. Todo se gestiona en un solo lugar y el contenedor incluso acepta parámetros, por lo que la conexión a la base de datos es fácilmente configurable. ¡Genial!

Pero ahora que ve solo dos métodos de ContainerInterface, quizás se sienta tentado a usar el contenedor de esta manera:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get('authenticator');
        //...
    }
}

Felicidades, acaba de quemar la pimienta. En otras palabras, el contenedor se ha convertido en un mal amo. ¿Por qué es esto?

Primero, se está basando en un identificador de servicio arbitrario: 'authenticator'. La inyección de dependencias se trata de ser transparente sobre sus dependencias, y usar un identificador artificial va directamente en contra de este concepto: hace que el código dependa silenciosamente de la definición del contenedor. Si alguna vez se renombra el servicio en el contenedor, debe encontrar esta referencia y actualizarla.

Y lo que es peor, esta dependencia está oculta: a primera vista desde fuera, el controlador solo depende de la abstracción del contenedor. Pero como desarrollador, usted debe tener conocimiento de cómo se nombran los servicios en el contenedor y que el servicio llamado authenticator es en realidad una instancia de Authenticator. Todo esto debe aprenderlo su nuevo colega. Innecesariamente.

Afortunadamente, podemos recurrir a un identificador mucho más natural: el tipo del servicio. Esto es, después de todo, lo único que le interesa como desarrollador. No necesita saber qué cadena aleatoria está asignada al servicio en el contenedor. Creo que este código es mucho más fácil de escribir y leer:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get(Authenticator::class);
        //...
    }
}

Desafortunadamente, aún no hemos domado las llamas. Ni un poco. El problema mayor es que está relegando humildemente el contenedor al rol de localizador de servicios, lo cual es un enorme antipatrón. Es como llevarle a alguien toda la nevera para que pueda sacar un snack; es mucho más sensato llevarle solo el snack.

Nuevamente, la inyección de dependencias se trata de transparencia, y este controlador todavía no es transparente sobre sus dependencias. La dependencia del autenticador está completamente oculta al mundo exterior detrás de la dependencia del contenedor. Esto hace que el código sea más difícil de leer. O de usar. ¡O de probar! Simular (mocking) el autenticador en un test unitario ahora requiere que cree todo un contenedor a su alrededor.

Y por cierto, el controlador todavía depende de la definición del contenedor, y de una manera bastante mala. Si el servicio autenticador no existe en el contenedor, el código fallará solo en el método action(), lo cual es una retroalimentación bastante tardía.

Cocinando algo sabroso

Para ser justos, nadie puede culparle por haber llegado a este callejón sin salida. Después de todo, solo siguió la interfaz diseñada y probada por desarrolladores inteligentes. El punto es que todos los contenedores de inyección de dependencias son, por definición, también localizadores de servicios, y resulta que ese patrón es realmente la única interfaz común entre ellos. Pero eso no significa que deba usarlos como localizadores de servicios. De hecho, la propia especificación PSR advierte contra ello.

Así es como puede usar el contenedor DI como un buen servicio:

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private Authenticator $authenticator,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $areCredentialsValid = $this->authenticator->authenticate($username, $password);
        //...
    }
}

En el constructor, la dependencia se declara explícitamente, de forma clara y transparente. Las dependencias ya no están ocultas dispersas por la clase. También se hacen cumplir: el contenedor no puede crear una instancia de SignInController sin proporcionar el Authenticator necesario. Si no hay ningún autenticador en el contenedor, la ejecución fallará prematuramente, no en el método action(). Probar esta clase también se ha vuelto mucho más fácil, ya que solo necesita simular el servicio autenticador sin ningún código repetitivo del contenedor.

Y un detalle más, pequeño pero muy importante: hemos introducido sigilosamente la información sobre el tipo del servicio. El hecho de que sea una instancia de Authenticator – anteriormente implícito y desconocido para el IDE, las herramientas de análisis estático o incluso para un desarrollador que no conozca la definición del contenedor – ahora está grabado estáticamente en la indicación de tipo del parámetro promocionado.

El único paso que queda es enseñar al contenedor cómo crear también el controlador:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Quizás haya notado que el contenedor todavía utiliza internamente el enfoque de localizador de servicios. Sin embargo, eso no importa siempre que esté contenido (juego de palabras intencionado). El único lugar fuera del contenedor donde la llamada al método get es permisible es en index.php, en el punto de entrada de la aplicación, donde es necesario crear el propio contenedor y luego cargar y ejecutar la aplicación:

$container = bootstrap();

$application = $container->get(Best\Framework\Application::class);
$application->run();

La joya escondida

Pero no nos quedemos ahí, permítame llevar esta afirmación más lejos: el único lugar donde la llamada al método get es permisible es el punto de entrada.

El código del contenedor es solo cableado, son instrucciones para el ensamblaje. No es código ejecutable. En cierto modo, no es importante. Aunque sí, es crucial para la aplicación, pero solo desde la perspectiva del desarrollador. En realidad, no aporta ningún valor directo al usuario y debería tratarse teniendo esto en cuenta.

Mire de nuevo el contenedor:

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Esto solo cubre un segmento muy pequeño y simple de la aplicación. A medida que la aplicación crece, escribir manualmente el contenedor se vuelve increíblemente tedioso. Como dije antes, el contenedor es solo un manual de montaje, pero es demasiado complejo, tiene muchas páginas, innumerables referencias cruzadas y un montón de advertencias escritas en letra pequeña. Queremos convertirlo en un manual estilo IKEA, gráfico, conciso y con ilustraciones de personas sonriendo mientras colocan el ÅUTHENTICATÖR sobre la alfombra durante el montaje para que no se rompa.

Aquí es donde entra en juego Nette Framework.

La solución DI de Nette Framework utiliza Neon, un formato de archivo de configuración similar a YAML, pero con esteroides. Así es como definiría el mismo contenedor usando la configuración Neon:

services:
    - SignInController
    - Authenticator
    - UserRepository
    - Connection(%database%)

Permítame señalar dos cosas notables: primero, la lista de servicios es realmente una lista, no un mapa hash; no hay claves, ni identificadores de servicio artificiales. No existe authenticator, ni Authenticator::class. Segundo, no necesita especificar explícitamente ninguna dependencia en ninguna parte, excepto los parámetros de conexión a la base de datos.

Esto se debe a que Nette Framework se basa en el cableado automático (autowiring). ¿Recuerda cómo, gracias a la inyección de dependencias, pudimos expresar el tipo de la dependencia con un typehint nativo? El contenedor DI utiliza esta información, por lo que cuando solicita una instancia de Authenticator, evita por completo cualquier nombre y encuentra el servicio correcto exclusivamente por su tipo.

Puede objetar que el autowiring no es una característica única. Y tendría razón. Lo que hace único al contenedor de Nette Framework es el uso del sistema de tipos de PHP, mientras que en muchos otros frameworks, el autowiring todavía se basa internamente en nombres de servicios. Hay escenarios en los que otros contenedores se quedan atrás. Así es como definiría el servicio autenticador en el contenedor DI de Symfony usando YAML:

services:
  Authenticator: ~

En la sección services, hay un mapa hash y la parte Authenticator es el identificador del servicio. La tilde significa null en YAML, lo que Symfony interpreta como “usa el identificador del servicio como su tipo”.

Pronto, sin embargo, los requisitos de negocio cambian y necesita soportar la autenticación a través de LDAP además de la búsqueda local en la base de datos. En el primer paso, cambia la clase Authenticator a una interfaz y extrae la implementación original a una clase LocalAuthenticator:

services:
  LocalAuthenticator: ~

De repente, Symfony está perdido. Esto se debe a que Symfony trabaja con nombres de servicios en lugar de tipos. El controlador todavía depende correctamente de la abstracción e indica la interfaz Authenticator como su dependencia, pero no hay ningún servicio con el nombre Authenticator en el contenedor. Debe darle una pista a Symfony, por ejemplo, usando un alias de nombre de servicio:

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, por el contrario, no necesita nombres de servicios ni pistas. No le obliga a duplicar en la configuración información que ya está expresada en el código (a través de la cláusula implements). Se posiciona directamente sobre el sistema de tipos de PHP. Sabe que LocalAuthenticator es de tipo Authenticator, y si es el único servicio que implementa esta interfaz, con gusto lo conectará automáticamente donde se requiera esta interfaz, basándose únicamente en esta línea de configuración:

services:
    - LocalAuthenticator

Reconozco que si no está familiarizado con el autowiring, puede parecerle un poco mágico y quizás necesite algo de tiempo para aprender a confiar en él. Afortunadamente, funciona de manera transparente y determinista: cuando el contenedor no puede resolver las dependencias de forma unívoca, lanza una excepción durante la compilación que le ayuda a corregir la situación. De esta manera, puede tener dos implementaciones diferentes y aun así tener un buen control sobre dónde se utiliza cada una.

En general, como desarrollador, el cableado automático le supone una menor carga cognitiva. Después de todo, solo se preocupa por los tipos y las abstracciones, ¿por qué debería el contenedor DI obligarle a preocuparse también por las implementaciones y los identificadores de servicios? Y lo que es más importante, ¿por qué debería preocuparse por algún contenedor en absoluto? En el espíritu de la inyección de dependencias, quiere poder simplemente declarar dependencias y que sea problema de alguien más proporcionarlas. Quiere concentrarse plenamente en el código de la aplicación y olvidarse del cableado. Y eso es lo que le permite el DI de Nette Framework.

A mis ojos, esto convierte a la solución DI de Nette Framework en la mejor que existe en el mundo de PHP. Le proporciona un contenedor que es fiable y promueve buenos patrones arquitectónicos, pero al mismo tiempo es tan fácil de configurar y mantener que no necesita pensar en él en absoluto.

Espero que este post haya logrado despertar su curiosidad. No olvide echar un vistazo al repositorio de Github y a la documentación; espero que descubra que solo le he mostrado la punta del iceberg y que todo el paquete es mucho más potente.