Inyección de Dependencias: La Clave para Aplicaciones Escalables
Una guía completa sobre cómo implementar la Inyección de Dependencias para mejorar la modularidad y flexibilidad de tus proyectos
Seguramente las inyecciones como a mi te traigan un par de recuerdos traumáticos de la infancia… pero descuida, no es de ese tipo de inyecciones de las que hablaremos hoy. La Inyección de Dependencias en el desarrollo de software es una técnica muy eficaz a la hora de construir aplicaciones robustas, escalables y fáciles de mantener.
A primera vista este puede parecer un concepto muy desafiante, pero no te preocupes, que para eso estoy yo aquí para explicártelo a lo largo de esta publicación, ya que repasaremos desde que es la Inyección de Dependencias, hasta los patrones y antipatrones relacionados con está técnica.
Introducción a la Inyección de Dependencias
La Inyección de Dependencias es un patrón de diseño fundamental en el desarrollo de software, ya que busca reducir las dependencias directas entre los componentes. Este patrón trabaja de la mano con la modularidad y facilita el mantenimiento y escalabilidad del código.
Y sí, ese párrafo suena muy bonito pero, ¿qué significa realmente la Inyección de Dependencias y cómo surge?
Conceptos Básicos
La Inyección de Dependencias es un método de proporcionar a un objeto las instancias de las clases (dependencias) de las que necesita para funcionar. En lugar de que el propio objeto cree estas instancias o utilice unas globales, las dependencias se inyectan, por lo regular al momento de crear el objeto.
Esta acción puede ser realizada por diferentes métodos como lo son:
La inyección de constructor.
La inyección de setter.
La inyección de interfaz.
La importancia
Lo que se busca al utilizar este patrón es la separación de responsabilidades. En lugar de que un objeto configure sus propias dependencias o realice la lógica de creación de objetos, delega esta responsabilidad a un agente externo. Trabajar con este enfoque promueve un diseño de código más limpio y modular, facilitando el testing, la gestión y la actualización del software.
Con este patrón el software se vuelve más fácil de probar, ya que al desacoplar las clases de sus dependencias, se pueden introducir implementaciones de prueba (por ejemplo mocks) que simulan el comportamiento de las dependencias reales en un entorno de prueba controlado. Lo cual se vuelve critico para pruebas unitarias eficaces, donde se necesita verificar el comportamiento de un componente en aislamiento.
Principios de Diseño de Software
En la arquitectura de software, los principios de diseño son la piedra angular para crear sistemas robustos, mantenibles y escalables. Aquí entran los principios SOLID que se destacan por llevar al desarrollo por un camino más limpio.
Seguro te preguntas, que tienen que ver los principios SOLID en una publicación sobre la Inyección de Dependencias, pues este patrón en particular, está intrínsecamente relacionado con estos principios, ya que su aplicación refuerza el cumplimiento de varios de ellos.
Los principios SOLID
Si quieres profundizar en los principios SOLID, este no es el lugar, ya que como su nombre lo indica, esta publicación se centra en la Inyección de Dependencias, así que simplemente veremos como se relaciona entre sí estos dos temas.
S (Principio de Responsabilidad Única): La Inyección de Dependencias ayuda a que un objeto o clase se enfoque en una sola tarea, dejando el manejo de dependencias a un manejador externo.
O (Principio de Abierto/Cerrado): Las clases se mantienen abiertas para la extensión pero cerradas pra la modificación, ya que las nuevas dependencias pueden inyectarse sin cambiar el código existente.
L (Principio de Sustitución de Liskov): La Inyección de Dependencias facilita el intercambio de implementaciones de dependencias siempre que sigan el contrato definido por la interfaz esperada.
I (Principio de Segregación de la Interfaz): Se promueve el uso de interfaces específicas, evitando la dependencia de interfaces o no necesarias.
D (Principio de Inversión de Dependencias): Este principio es la base del patrón, ya que los módulos de alto nivel no dependen de módulos de bajo nivel, sino de abstracciones.
Como puedes ver, cada uno de estos principios se aplica en distintos grados cuando se utiliza la Inyección de Dependencias, creando un diseño que busca ser fácil de entender, probar y mantener.
Sí quieres profundizar sobre los principios SOLID, te recomiendo que te pases por mi publicación:
Ahí te explico a detalle cada uno de los principios, te doy ejemplos en código de como aplicarlos y por si eso fuera poco, cada explicación está realizada usando bloques de LEGO, así que vale la pena echarle un ojito, o dos 😉
Tipos de Inyección de Dependencias
Como ya vimos en una sección anterior, la Inyección de Dependencias puede realizarse de varias maneras, dependiendo de cómo se suministran las dependencias al componente que las necesita. Así que veamos los métodos y ejemplos de cada uno de ellos.
Inyección de Constructor
Este método suministra las dependencias a través del constructor de la clase. Es una de las formas más comunes de Inyección de Dependencias y se considera una buena práctica porque las dependencias son provistas antes de que se utilice el objeto, asegurando que el objeto siempre esté en un estado válido.
public class Customer {
private Service service;
public Customer(Service service) {
this.service = service;
}
}
Service myService = new SpecificService();
Customer customer = new Customer(myService);
En este ejemplo Customer
, requiere un Service
. Al crear un nuevo Customer
, se inyecta un SpecificService
a través de su constructor.
Inyección de Setter
La inyección de setter utiliza métodos establecedores (setters) para inyectar dependencias. Esto permite cambiar las dependencias del objeto después de que ha sido construido. Es menos común que la inyección de constructor, ya que puede dejar al objeto en un estado incompleto si los setters no se llaman después de la creación del objeto.
public class Client {
private Service service;
public void setService(Service service) {
this.service = service;
}
}
Client client = new Client();
client.setService(new SpecificService());
Aquí, después de instanciar Client
, se inyecta SpecificService
utilizando el método setService
.
Inyección de Interfaz
La inyección de interfaz se refiere a la inyección de dependencias a través de métodos definidos en una interfaz que el componente concreto implementa. No es un tipo de inyección de dependencias por sí mismo como los dos anteriores, sino más bien un patrón que puede ser utilizado junto con la inyección de constructor o de setter para proporcionar la configuración de las dependencias.
public interface ServiceInjector {
void injectService(Client client);
}
public class SpecificServiceInjector implements ServiceInjector {
public void injectService(Client client) {
Service service = new ConcreteService();
client.setService(service);
}
}
Client client = new Client();
ServiceInjector injector = new SpecificServiceInjector();
injector.injectService(client);
En este caso, SpecificServiceInjector
implementa la interfaz ServiceInjector
y sabe cómo inyectar el Servicio
en el Cliente
. Luego, se usa injector.injectService(client)
para inyectar las dependencias.
Contenedores de Inyección de Dependencias
Los Contenedores de Inyección de Dependencias son herramientas que gestionan la creación e inyección de dependencias en una aplicación. El uso de contenedores facilita la implementación del patrón al encargarse de la creación de objetos y de la resolución de sus dependencias de manera automática, basándose en una configuración previa.
Qué Son y Cómo Funcionan
El contenedor actua como una fábrica que suministra instancias de clases listas para ser utilizadas. Estos contenedores permiten definir en un lugar centralizado cómo y cuándo se deben crear los objetos y sus dependencias asociadas. Al arrancar la aplicación, el contenedor se encarga de instancias las clases y resolver sus dependencias a través de la inyección de constructor u otro método definido.
Ejemplos de Contenedores
Spring Framework (Java)
Spring es posiblemente el contenedor más conocido en el ecosistema de Java. Utiliza anotaciones o archivos XML para configurar las dependencias.
@Component
public class Client {
private final Service service;
@Autowired
public Client(Service service) {
this.service = service;
}
}
En este caso, Spring se encargará de buscar una implementación de Service
que haya sido marcada como un componente (con @Component
u otra anotación similar) y la inyectará automáticamente al crear una instancia de Client
.
.NET Core DI (C#)
La plataforma .NET Core incluye un sistema de inyección de dependencias que permite registrar y resolver dependencias con varias vidas útiles: singleton, scoped o transient.
public void ConfigureServices(IServiceCollection services) {
services.AddTransient<IService, ConcreteService>();
}
public class Client {
private IService _service;
public Client(IService service) {
_service = service;
}
}
Aquí, la implementación ConcreteService
de la interfaz IService
se registra con una vida útil transitoria. Cada vez que IService
sea requerido, el contenedor de .NET Core instanciará un nuevo ConcreteService
.
Ciclo de Vida de las Dependencias
La gestión del ciclo de vida de las dependencias es un aspecto a tener en cuenta, ya que se refiere al tiempo de vida y alcance que tendrán las instancias de los objetos creados por un contenedor. Comprender cómo y cuándo se crean, se reutilizan y se destruyen estas instancias es fundamental para evitar errores comunes como fugas de memoria o instancias no actualizadas.
Los tres ciclos de vida más comunes en la Inyección de Dependencias son: Singleton
, Prototype
y Scoped
.
Singleton
El patrón Singleton garantiza que una clase solo tenga una instancia y proporciona un punto de acceso global a esa instancia. En el contexto de la inyección de dependencias, al registrar una dependencia como Singleton
, el contenedor crea una única instancia de esa dependencia y la reutiliza en todas las solicitudes.
@Bean
@Scope("singleton")
public Service serviceSingleton() {
return new ConcreteService();
}
En este ejemplo de Spring, el método serviceSingleton
está anotado con @Scope("singleton")
, lo que indica que Spring debe crear y mantener una única instancia de ConcreteService
.
Prototype
A diferencia del Singleton
, el ciclo de vida de Prototype
crea una nueva instancia de la clase cada vez que se solicita. Esto es útil cuando necesitas una instancia independiente y con estado que no se comparta entre diferentes partes de la aplicación.
@Bean
@Scope("prototype")
public Service servicePrototype() {
return new ConcreteService();
}
Aquí, cada vez que se inyecte Service
, Spring creará una nueva instancia de ConcreteService
.
Scoped
El ciclo de vida Scoped
proporciona un término medio entre Singleton
y Prototype
. Scoped
crea una instancia única para un alcance específico, como una solicitud web o una sesión de usuario. En un entorno web, por ejemplo, un objeto Scoped podría crearse una vez por solicitud y ser compartido por todos los componentes que procesan esa solicitud.
services.AddScoped<IServicio, ConcreteService>();
En .NET Core, el método AddScoped
registra ConcreteService
como Scoped, lo que significa que una instancia será compartida dentro del contexto de una solicitud web, pero no entre diferentes solicitudes.
Gestión del Ciclo de Vida
La gestión del ciclo de vida implica comprender las necesidades de la aplicación y las características de las dependencias. Por ejemplo, un servicio que rara vez cambia puede ser un Singleton
para evitar la sobrecarga de crear múltiples instancias. Por otro lado, un objeto que representa una transacción de base de datos debe tener un ciclo de vida más corto y ser Scoped
o Prototype
para asegurar que el estado de la transacción de maneje correctamente.
La elección del ciclo de vida de una dependencia es una decisión de diseño importante que puede tener un impacto significativo en la funcionalidad, el rendimiento y la escalabilidad de una aplicación.
Inyección de Dependencias en diferentes paradigmas de programación
La Inyección de Dependencias es un patrón de diseño aplicable y útil en varios paradigmas de programación, aunque su implementación y uso pueden variar. Así que a continuación revisemos cómo se aplica la Inyección de Dependencias en la programación orientada objetos, la funcional y la reactiva.
Programación Orientada a Objetos (POO)
La POO se basa en el concepto de objetos y clases, aquí la Inyección de Dependencias es natural. Los objetos definen estados y comportamientos, y las clases pueden depender de otras para funcionar. La Inyección de Dependencias en este paradigma permite crear sistemas desacoplados y fáciles de mantener, donde las relaciones entre clases no están codificadas rígidamente, sino definidas a través de abstracciones como interfaces o clases base.
// Ejemplo en Java (POO)
public interface Repository {
void save(Data data);
}
public class Service {
private Repository repository;
public Service(Repository repository) {
this.repository = repository;
}
public void processData(Data data) {
repository.save(data);
}
}
En este ejemplo, Service
depende de una abstracción Repository
, y la implementación concreta se inyecta, permitiendo cambiar el comportamiento de almacenamiento sin modificar Service
.
Programación Funcional
La programación funcional se centra en funciones puras y el flujo de datos inmutables. La Inyección de Dependencias puede ser menos obvia en la programación funcional porque no se manejan estados u objetos de la misma manera que en la programación orientada a objetos. Sin embargo, la Inyección de Dependencias aún se aplica pasando funciones o módulos como argumentos a otras funciones, lo que permite intercambiar comportamientos y facilitar las pruebas.
-- Ejemplo en Haskell (Programación Funcional)
processData :: (Data -> IO ()) -> Data -> IO ()
processData repositorySave data = do
repositorySave data
Aquí, processData
es una función que toma otra función repositorySave
como argumento, que se encarga de guardar el dato. Esta es una forma de Inyección de Dependencias en la programación funcional, donde las dependencias son funciones.
Programación Reactiva
La programación reactiva gestiona flujos de datos y la propagación de cambios utilizando observables y suscriptores. La Inyección de Dependencias en este paradigma se puede ver en la inyección de servicios que retornan observables o se suscriben a ellos.
// Ejemplo en Java con RxJava (programación reactiva)
public class ReactiveService {
private final Observable<Data> dataObservable;
public ReactiveService(Observable<Data> dataObservable) {
this.dataObservable = dataObservable;
}
public void listenToData() {
dataObservable.subscribe(data -> {});
}
}
En este ejemplo con RxJava, ReactiveService
depende de un Observable
que emite Data
. La fuente de datos se inyecta en el servicio, lo que permite cambiar las fuentes de datos sin modificar el código que maneja los datos.
Buenas Prácticas y Patrones de Diseño
La implementación efectiva de la Inyección de Dependencias no solo implica comprender qué es y cómo usarla, sino también reconocer las buenas prácticas y patrones de diseño que ayudan a maximizar sus beneficios. Además, es esencial evitar ciertos antipatrones que pueden contrarestar las ventajas que ofrece la Inyección de Dependencias.
Factory Pattern
Este es un patrón de diseño creacional que se utiliza para crear objetos. En el contexto de la Inyección de Dependencias, un Factory es responsable de encapsular la lógica de creación de un objeto y sus dependencias, lo cual lo vuelve útil cuando la construcción del objeto es compleja.
public class ConnectionFactory {
public Connection createConnection(String type) {
if (type.equals("MYSQL")) {
return new MySQLConnection();
} else if (type.equals("H2")) {
return new H2Connection();
}
throw new IllegalArgumentException("Connection type not supported.");
}
}
En este ejemplo, ConnectionFactory
tiene un método createConnection
que crea y retorna diferentes tipos de conexiones según el parámetro tipo
. Este patrón oculta los detalles de cómo se crean las instancias de Connection
, y la fábrica en sí puede ser inyectada donde sea necesario.
Service Locator
Este patrón es a menudo visto como una alternativa a la Inyección de Dependencias, donde un servicio central localiza y proporciona las dependencias en lugar de inyectarlas directamente. Aún así, este patrón puede llevar a un acoplamiento más fuerte y a un código menos transparente y más difícil de probar, ya que oculta las dependencias y hace que el rastr4eo de errores se vuelva más complejo.
public class ServiceLocator {
private static final Map<Class<?>, Object> services = new HashMap<>();
public static void registerService(Class<?> key, Object instance) {
services.put(key, instance);
}
public static <T> T getService(Class<T> key) {
return key.cast(services.get(key));
}
}
Aunque el ServiceLocator
puede parecer práctico, su uso puede resultar en un 'anti-patrón' si se abusa de él, ya que introduce un alto grado de acoplamiento global en la aplicación.
Antipatrones a Evitar
Un antipatrón se puede entender como una solución ineficaz a un problema recurrente. A diferencia de los patrones de diseño que ofrecen una forma probada y eficiente de resolver un problema de diseño específico, los antipatrones son enfoques que parecen ser soluciones adecuadas pero que en realidad pueden resultar contraproducentes.
A menudo los antipatrones son el resultado de prácticas deficientes, falta de experiencia o de entender mal los problemas subyacentes y pueden conducir a código que se vuelve difícil de leer, mantener o escalar.
Reconocer y evitar antipatrones es importante porque pueden llevar a problemas a largo plazo, por lo que a continuación te presento los antipatrones a evitar en la Inyección de dependencias.
Inyección de Dependencias Ocultas. Evita ocultar las dependencias de una clase utilizando Service Locators o Factory dentro de las clases en lugar de inyectarlas. Esto puede hacer que el código se vuelva complejo.
Configuración Excesivamente Compleja. Un exceso de configuración en los contenedores puede hacer que el código sea difícil de seguir y mantener. La configuración debe ser tan simple como sea posible.
Sobreutilización. No todas las dependencias necesitan ser inyectadas. A veces, el uso de
new
es perfectamente aceptable, especialmente para objetos que son puramente de datos y no tienen dependencias.Inyección de Dependencias en donde sea. La Inyección de Dependencias no es una solución universal. Detente a evaluar si realmente proporciona un beneficio en cada caso de uso específico.
La Inyección de Dependencias no es solo una técnica de diseño de software, de hecho me atrevería a decir que es una filosofía que impulsa la creación de aplicaciones más limpias y escalables.
Adoptar la Inyección de Dependencias en tus proyectos te permitirá flexibilidad en la arquitectura de tus aplicaciones. Es por esto que espero que este recorrido te ayuda a tener una base sólida para su implementación en tus futuros proyectos.
Nos leemos el próximo jueves, donde seguiré trayendo temas como este de una forma lo más digerible posible. Hasta la próxima 👋🏻
Wow, el del setter no lo habia usado, supongo que esta bien saberlo por si alguna vez se ocupa jsjs igual lo del anti patron de usar un service locator o factory dentro de una clase jamas se me habia ocurrido hacerlo jeje, pero igual esta bueno tenerlo en cuenta y conocer activamente por que no hacerlo o por si te lo encuentras en codigo que no sea tuyo jsjs