13 - Dependencias entre clases
En el mundo del desarrollo de software, la relación entre clases es esencial para la construcción de sistemas robustos y mantenibles. La dependencia entre clases es una pieza fundamental en este entorno, donde una clase puede requerir los servicios o funcionalidades proporcionadas por otra para llevar a cabo su tarea.
Sin embargo, una dependencia fuerte entre clases puede llevar a un acoplamiento excesivo, lo que dificulta la flexibilidad y la reutilización del código. Es aquí donde entra en juego el concepto de desacoplamiento, que busca reducir la dependencia entre clases y promover la modularidad y la escalabilidad en el diseño de software.
En este contexto, es crucial entender los diferentes mecanismos que tenemos a nuestra disposición para lograr este desacoplamiento. Entre ellos, destacan la Inyección de Dependencias (DI) y el Inversión de control (IoC), que proporcionan técnicas efectivas para separar la configuración y la creación de objetos de su uso real, facilitando así la gestión de dependencias y la modularidad del código.
En este breve recorrido, exploraremos la importancia del desacoplamiento entre clases y cómo los mecanismos de DI y IoC nos ayudan a alcanzar este objetivo.
Inyección de dependencias (DI)
La Inyección de Dependencias (DI) es un principio fundamental en el diseño de software moderno que aborda la problemática del acoplamiento entre clases al permitir que las dependencias sean proporcionadas desde el exterior en lugar de ser creadas internamente. Este enfoque promueve la flexibilidad y la reutilización del código al separar la configuración y la creación de objetos de su uso real.
Para ilustrar la importancia de la DI, consideremos un ejemplo simple. Tenemos una interfaz InterfazX con dos implementaciones: ClaseC y ClaseD. Además, tenemos una clase ClaseB que depende de InterfazX para realizar sus operaciones. En la clase ClaseA, deseamos utilizar ClaseB, pero queremos poder elegir entre ClaseC y ClaseD como la implementación de InterfazX sin modificar directamente ClaseB. Usando DI podemos proporcionar flexibilidad al diseño del software al permitir la elección de implementaciones concretas de las dependencias en tiempo de ejecución, lo que facilita el mantenimiento y la evolución del código:
public interface InterfazX { void metodoX(); } public class ClaseC implements InterfazX { public void metodoX() { System.out.println("Método X de ClaseC"); } } public class ClaseD implements InterfazX { public void metodoX() { System.out.println("Método X de ClaseD"); } } public class ClaseB { private InterfazX instanciaX; public ClaseB(InterfazX instanciaX) { this.instanciaX = instanciaX; } public void operar() { instanciaX.metodoX(); } } public class ClaseA { public void metodoAConClaseC() { InterfazX instanciaClaseC = new ClaseC(); ClaseB claseB = new ClaseB(instanciaClaseC); claseB.operar(); } public void metodoAConClaseD() { InterfazX instanciaClaseD = new ClaseD(); ClaseB claseB = new ClaseB(instanciaClaseD); claseB.operar(); } }
En este ejemplo, ClaseA tiene dos métodos, metodoAConClaseC() y metodoAConClaseD(), cada uno de los cuales crea una instancia de ClaseB utilizando una implementación diferente de InterfazX mediante la inyección de dependencias.
Además, la utilización de DI nos brinda la flexibilidad necesaria para substituir implementaciones con mocks durante las pruebas unitarias. Ésto nos permite simular diferentes escenarios y comportamientos de las dependencias, facilitando así la identificación y corrección de posibles fallos en el código. De esta manera, la DI no solo promueve la modularidad y la flexibilidad del código en nuestra arquitectura, sino que también mejora significativamente la calidad y la robustez de nuestras pruebas unitarias.
En una arquitectura por capas, la DI emerge como una herramienta fundamental para promover la modularidad y la flexibilidad del código. Con DI, podemos desacoplar las capas de nuestra aplicación al proporcionar las dependencias necesarias desde el exterior en lugar de crearlas internamente. Esto facilita la sustitución de implementaciones y la realización de pruebas unitarias, lo que contribuye a un diseño más mantenible y evolutivo:
@Controller public class BookController { private BookService bookService; public BookController() { BookRepository bookRepository = new BookRepositoryImpl(); this.bookService = new BookServiceImpl(bookRepository); }
public interface BookService { } public class BookServiceImpl implements BookService { private BookRepository bookRepository; public BookServiceImpl(BookRepository bookRepository) { this.bookRepository = bookRepository; } }
public interface BookRepository { ... } public class BookRepositoryImpl implements BookRepository { ... }
En nuestra arquitectura por capas, aprovechamos el poder de Spring para simplificar la gestión de controladores mediante la creación automática de Beans. Esto nos permite centralizar la lógica de enrutamiento y la gestión de solicitudes de manera eficiente.
Sin embargo, en esta etapa inicial, optamos por crear manualmente nuestros servicios en lugar de utilizar la inyección de dependencias automática que ofrece Spring (como por ejemplo, con la anotación @Autowired). Esta decisión nos brinda un mayor control sobre la configuración y la gestión de nuestras dependencias, permitiéndonos comprender mejor los fundamentos de la Inyección de Dependencias (DI).
En nuestro ejemplo, BookController crea una instancia de BookServiceImpl y BookRepositoryImpl manualmente en su constructor. Esta aproximación, aunque más rudimentaria, nos permite comprender cómo se gestionan las dependencias en una arquitectura por capas y nos prepara para explorar en futuras etapas el uso más avanzado de la inyección de dependencias automática en Spring.
El problema con el ejemplo anterior es que el controlador está creando objetos de tipo repositorio, con lo que la capa de presentación se está comunicando directamente con la de persistencia. Esto presenta un problema de acoplamiento indebido, ya que estamos mezclando la lógica de presentación con la lógica de acceso a datos.
Para resolver este problema, podemos usar el principio de Inversión de Control (IoC). IoC es un patrón de diseño que busca invertir el control de la creación y gestión de objetos. En lugar de que una clase controle la creación de sus propias dependencias, estas se inyectan desde el exterior.
Al aplicar IoC en nuestro sistema, podemos desacoplar el controlador de las implementaciones concretas de los repositorios. Esto promueve un diseño más modular y flexible, donde cada capa de la aplicación tiene una responsabilidad claramente definida.
Inversión de Control (IoC)
La Inversión de Control (IoC) es un principio de diseño de software que busca invertir el control de la creación y gestión de objetos. Tradicionalmente, en la programación orientada a objetos, una clase es responsable de crear y gestionar sus propias dependencias. Sin embargo, con IoC, esta responsabilidad se traslada a un componente externo, el contenedor IoC.
El contenedor IoC es una infraestructura que se encarga de administrar la creación y gestión de objetos en una aplicación. Al delegar esta responsabilidad al contenedor IoC, las clases se vuelven más flexibles y desacopladas, lo que facilita la configuración, la extensión y el mantenimiento del sistema.
Existen varios mecanismos para implementar IoC, siendo la DI uno de los más utilizados. Con DI, las dependencias de una clase se pasan como parámetros en lugar de ser creadas internamente. Esto permite que las clases sean más flexibles y reutilizables, ya que pueden recibir diferentes implementaciones de sus dependencias sin modificar su código interno.
Por ejemplo, vamos a crear un contenedor de IoC para gestionar la creación de objetos de tipo servicio y repositorio:
public class BookIoCContainer { private static BookService bookService; private static BookRepository bookRepository; public static BookService getBookService() { if(bookService == null) { bookService = new BookServiceImpl(getBookRepository()); } return bookService; } public static BookRepository getBookRepository() { if(bookRepository == null) { bookRepository = new BookRepositoryImpl(); } return bookRepository; } }
En este caso, hemos creado los métodos estáticos para simplificar el proceso. Al utilizar este contenedor, podemos obtener instancias de estas clases sin tener que crearlas directamente en nuestros componentes.
Fíjate que cuando creamos el servicio creamos la instancia de repositorio también, y, aprovechando la DI, se la pasamos al constructor del servicio.
Nuestro controlador quedaría:
public class BookController { private final BookService bookService; public BookController() { this.bookService = BookIoCContainer.getBookService(); }
Es importante tener en cuenta que este contenedor de IoC es una implementación simplificada y manual. En aplicaciones más grandes y complejas, es común utilizar frameworks como Spring Framework, que proporcionan funcionalidades más avanzadas para la gestión de IoC, como la inyección de dependencias automática y la configuración basada en anotaciones. Estas herramientas simplifican aún más el desarrollo y la gestión de aplicaciones basadas en IoC.
Ejercicios
Ejercicio 1
Modifica la práctica de la evaluación anterior para añadir DI y IoC.