Los servicios no necesitan nombres
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.
Para enviar un comentario, inicie sesión