Inversión de Control en Arquitectura Frontend
Alex Castells
Posted on January 26, 2021
DDD, arquitectura hexagonal, SOLID, IoC...
Backend? Frontend? O simplemente principios del software para la programación orientada a objetos?
En este post daremos un paseo por la arquitectura del software, como método de diseño agnóstico al frontend y al backend para ver las similitudes entre ambos contextos.
Eso sí, usaré la inversión de control como referencia, dado que siendo quizá el principio que más fácil de ver es cómo habilita el diseño de software modular, es a la vez el que más frameworks "estandarizados" tiene cuando hablamos de backend (p.ej. Spring), pero el que parece tener menos en frontend.
También aprovecharé al final para introduciros a brusc, una pequeña librería que desarrollé hará poco más de un año para habilitar la inyección de dependencias en proyectos de ES6.
Introducción a clean architectures
Muchos de los conceptos a la hora de hablar de clean architectures, best practices, principios de diseño, ... se basan en solucionar lo mismo: cómo organizar los distintos componentes de nuestro software en capas para maximizar su cohesión y minimizar el acoplamiento.
A la hora de representar el comportamiento de una aplicación, cómo se puede interactuar con ella, qué sucede con las interacciones y cómo navegan los datos, a mi personalmente me gusta hablar de:
- Actores: quién inicia las interacciones (usuario, tiempo, ...) y para qué.
- Interfaces de acceso: de qué disponen los actores para interactuar (UI, CLI, ...).
- Infraestructura de acceso: cómo debe habilitarse un acceso para una interfaz concreta (comandos, controladores, ...)
- Casos de uso (o servicios de aplicación): cómo permitimos la interacción del exterior hacia nuestro dominio para consultarlo o manipular su estado.
- Dominio: donde reside la abstracción de nuestro negocio (entidades de negocio, definiciones de repositorios, ...) para que los casos de uso puedan llevar a cabo su cometido.
- Infraestructura de salida: cómo debe habilitarse una salida concreta hacia otro sistema que nos permita la recuperación y almacenaje del estado de nuestro dominio (APIs HTTP, BBDD, ...)
Hay muchas otras formas de expresarlo, pero la idea general de todas ellas es que desde la concreción de infraestructura hacia la abstracción de la lógica de negocio (dominio), hay una flecha unidireccional de acceso por las diferentes capas, para evitar que los componentes lógicos se vean afectados por cambios de infraestructura (The Dependency Rule].
Por ejemplo, nuestro dominio debería ser el mismo, así como los casos de uso que se exponen para habilitar la interacción con él, independientemente de si sustituimos el acceso a la aplicación mediante línea de comandos por un acceso mediante UI.
Una forma de representar esto puede ser mediante arquitectura hexagonal
Frontend, Backend, puede ser lo mismo desde la perspectiva OOP
Para empezar a hablar de estos conceptos aplicados al frontend, veamos una representación muy esquemática de arquitectura hexagonal para una aplicación "típica" de backend accesible vía API:
Suponiendo que el servicio fuera para poder realizar búsquedas de libros, el "foco" del desarrollador sería:
- Definir el dominio que represente la lógica esperada de este servicio (domain), p.ej: Book como entidad, BookRepository como representación de las operaciones necesarias para recuperarlo.
- Definir los casos de uso para exponer las interacciones sobre este dominio al exterior (application), p.ej: SearchBooksUseCase
- Definir la recuperación o almacenaje concretos (infrastructure), p.ej: disponemos de una base de datos MySql y deberíamos implementar las operaciones de la abstracción de dominio BookRepository como por ejemplo, JdbcBookRepository o MySqlBookRepository
- Definir los controladores HTTP del servicio para habilitar los accesos vía API (infrastructure), p.ej: BookController
Y aquí ya saldría un problema si tenemos en cuenta la (Dependency Rule]: ¿Cómo puede el caso de uso recuperar los libros de la base de datos sin saber que el repositorio de libros debe acceder a una base de datos? ¿Cómo recibe la implementación concreta para MySql?
Pues precisamente aquí es donde entra en juego la inversión de control.
Si nuestro caso de uso depende de un repositorio para hacer su labor, siguiendo la D de los principios SOLID, el caso de uso SearchBooksUseCase debe depender de una abstracción (BookRepository), no de una concreción (MySqlBookRepository), dado que el caso de uso no debería verse afectado si el día de mañana cambiamos MySql por Oracle, o incluso si cambiamos el almacenaje de libros a una API de terceros accesible por HTTP en lugar de por JDBC.
Podríamos representar la inversión de control de dependencias así:
Y para lograrlo, podríamos implementar esta inversión de control con el patrón de Inyección de Dependencias.
Otra forma de lograrlo es mediante el patrón Service Locator
La de inyección de dependencias basada en framework de infraestructura consiste en un contenedor de dependencias capaz de proveer de una implementación concreta a partir de una abstracción (o declaración) y un inyector de dependencias que usará esa funcionalidad del contenedor para proveer al cliente esas dependencias ocultándole la implementación.
De forma esquemática, lo que acaba sucediendo es esto:
Y con todo lo anterior en mente... xD, ahora sí: toca hablar de cómo aplica el mismo concepto en el desarrollo de frontend.
Supongamos que queremos desarrollar la UI web de un sistema de gestión de libros.
Supongamos además que no es sólo la UI entendida como componentes HTML y CSS, sino que tenemos asociada una lógica de negocio y debemos desarrollar una serie de casos de uso que sólo aplican al entorno web.
Si aplicáramos las mismas metodologías y terminología para el desarrollo de software al que hacía referencia cuando describía el sistema para ser accedido como API de backend, volveríamos a hablar de dominio, casos de uso, infraestructura de acceso, infraestructura de salida, ... por lo que esquematizando el mismo concepto con arquitectura hexagonal veríamos algo como:
Sólo que en este caso, por ejemplo, veríamos que la infraestructura necesaria para poder recuperar los libros debería ser representada con un acceso vía HTTP a la API de backend, y podríamos representar el caso de uso de búsqueda de libros hasta su repositorio concreto así:
De este modo, nuestra implementación de componentes de UI no tiene que saber nada sobre cómo está implementada internamente la aplicación JS de gestión de libros, sólo debe conocer qué accesos se le han habilitado para poder interactuar con ella, y esos accesos no sufrirán cambios si nuestra tecnología para accederlos es una UI en React, en Angular, en Vue, o un test.
Inversión de control en Javascript
Ya sea en ES6 o en Typescript, el concepto es el mismo.
Yo me centraré en ES6 porque es lo que suelo usar en el día a día, y para hablaros de brusc, pero podéis echarle un ojo a inversify que es también un framework muy potente orientado a proyectos Typescript.
Para entender mejor la inversión de control, os pongo primero un ejemplo de lo que no es, para que veamos qué problemas supone y cómo lo evolucionamos a un mejor diseño, partiendo de la base de la librería para la gestión de libros.
Supongamos que queremos cumplir este expect:
it('should find a book', async () => {
const givenQuery = 'Sin Noticias De Gurb'
const books = await Books.searchBooks({query: givenQuery})
expect(
books.filter(book => book.title === givenQuery).length
).to.greaterThan(0)
})
Podríamos implementar la solución así:
simplificado a fichero único y sin entidad de dominio
class Books {
constructor() {
this._searchBooksUseCase = new SearchBooksUseCase()
}
searchBooks({query}) {
return this._searchBooksUseCase.execute({query})
}
}
class SearchBooksUseCase {
constructor() {
this._bookRepository = new HttpOpenLibraryBookRepository()
}
execute({query}) {
return this._bookRepository.find({query})
}
}
import axios from 'axios'
class HttpOpenLibraryBookRepository {
constructor() {
this._libraryApi = 'http://openlibrary.org'
}
find({query}) {
return axios
.get(`${this._libraryApi}/search.json?q=${query}`)
.then(response => response.data.docs)
}
}
const books = new Books()
export default books
Aunque el test pasaría, esto tiene varias que me harían llorar:
- Cada clase se está responsabilizando de la construcción de sus dependencias.
- Todo depende de concreciones.
- No es posible sustituir una implementación por una extensión de la misma, ¿cómo testearíamos el caso de uso individualmente sin poder reemplazar la implementación HTTP del repositorio por p.ej. un stub?
- ¿Y si tuviéramos que implementar un nuevo caso de uso que dependiera del mismo repositorio, lo inicializaríamos de nuevo? ¿Y si algún día quisiéramos cambiar OpenLibrary por otra API, en cuántos casos de uso deberíamos reemplazar el repositorio?
Deberíamos iterar esta solución, aunque evidentemente es mejor que si usáramos directamente un fetch desde un componente de UI, dado que a medida que el proyecto tuviera más necesidades, estos problemas se multiplicarían y cada vez se haría menos extensible y menos mantenible.
Otra opción: Aplicando la inversión de control a mano
class Books {
constructor({searchBooksUseCase}) {
this._searchBooksUseCase = searchBooksUseCase
}
searchBooks({query}) {
return this._searchBooksUseCase.execute({query})
}
}
class SearchBooksUseCase {
constructor({bookRepository}) {
this._bookRepository = bookRepository
}
execute({query}) {
return this._bookRepository.find({query})
}
}
import axios from 'axios'
class HttpOpenLibraryBookRepository {
constructor() {
this._libraryApi = 'http://openlibrary.org'
}
find({query}) {
return axios
.get(`${this._libraryApi}/search.json?q=${query}`)
.then(response => response.data.docs)
}
}
class BooksInitializer {
static init() {
const bookRepository = new HttpOpenLibraryBookRepository()
const searchBooksUseCase = new SearchBooksUseCase({bookRepository})
return new Books({searchBooksUseCase})
}
}
const books = BooksInitializer.init()
export default books
Esto ya empezaría a coger otra forma:
- El caso de uso no conoce la implementación del repositorio.
- Esta implementación podría ser sustituida en un test unitario del caso de uso o por una implementación distinta en el "initializer", y el caso de uso no se vería afectado.
Aún así, si el proyecto empezara a crecer en casos de uso y repositorios, nos podríamos encontrar con los siguientes problemas:
- Todas las dependencias deben ser inicializadas en un orden específico, añadiendo complejidad a futuros cambios a medida que el proyecto crece.
- Si el caso de uso de repente necesitara una nueva dependencia, se debería sincronizar la inicialización también en el "initializer", y podría provocar un reordenamiento de otras dependencias.
Y aquí podría entrar la inyección de dependencias mediante framework, como por ejemplo usando brusc:
const inject = key => inject.provide(key)
const TYPES = {
searchBooksUseCase: 'searchBooksUseCase',
bookRepository: 'bookRepository'
}
class Books {
constructor({searchBooksUseCase = inject(TYPES.searchBooksUseCase)} = {}) {
this._searchBooksUseCase = searchBooksUseCase
}
searchBooks({query}) {
return this._searchBooksUseCase.execute({query})
}
}
class SearchBooksUseCase {
constructor({bookRepository = inject(TYPES.bookRepository)} = {}) {
this._bookRepository = bookRepository
}
execute({query}) {
return this._bookRepository.find({query})
}
}
import axios from 'axios'
class HttpOpenLibraryBookRepository {
constructor() {
this._libraryApi = 'http://openlibrary.org'
}
find({query}) {
return axios
.get(`${this._libraryApi}/search.json?q=${query}`)
.then(response => response.data.docs)
}
}
import Brusc from 'brusc'
class BooksInitializer {
static init() {
Brusc.define(inject)
.singleton(TYPES.searchBooksUseCase, () => new SearchBooksUseCase())
.singleton(TYPES.bookRepository, () => new HttpOpenLibraryBookRepository())
.create()
return new Books()
}
}
const books = BooksInitializer.init()
export default books
Aunque la solución tampoco es perfecta debido a las limitaciones propias del lenguaje, que para Brusc implica requerir la definición de una función inject
accesible para todos los componentes de la librería (y opcionalmente las claves para los tipos), igual que sucede con Inversify y el uso de los decoradores para la inyección, usar una librería para como Brusc nos ofrecerá varios beneficios:
- Facilidad de bootstrapping de la librería, sin necesidad de tener que pensar en el orden de inicialización de instancias (pueden ser agrupadas por capas, intención, ...)
- Protección frente a dependencias circulares (se lanzaría error de inicialización en lugar de quedarse en un bucle infinito)
- Declaración clara de instancias en el contenedor (singletons para instancias reusables, prototipos para instancias con estado)
- Posible instrumentalización de las instancias en el contenedor (ver adapters de Brusc)
Y por último pero no menos importante, en el caso concreto de Brusc:
- Pensado para facilitar la implementación de tests de integración usando los
inject.defaults
para sustituir instancias del contenedor durante la ejecución de un test.
Pros y contras
Para terminar, teniendo en cuenta que las guías de diseño, principios, patrones y demás están ahí para darnos herramientas que nos faciliten tomar decisiones en el desarrollo, pero nunca hay una única ni mejor manera de implementar una aplicación, me gustaría comentar algunos pros y contras de aplicar arquitecturas limpias en frontend, para animaros a usarlas pero también para evitar desengaños xD
Contras
El tamaño final de la solución se verá incrementado: Si bien nos puede compensar la mantenibilidad, testabilidad, ... en proyectos grandes, introducir dependencias o hacer una separación muy granular de las capas, nos va a incrementar el tamaño del distribuible final, cosa que debemos contemplar cuando se trata de un fichero que terminará siendo descargado desde terminales móviles.
Se debe escribir más código para poder representar cada entidad, repositorio, caso de uso, ... Más código ejecutable implica más código a mantener.
Dependencia a frameworks/librerías, ya sea Brusc, inversify o cualquier otra, incluso privada, para implementar de otra manera la inversión de control.
Pros
Baja curva de aprendizaje (y mantenibilidad): aplicar una arquitectura homogénea a todos los proyectos posibles (incluso independientemente del contexto de ejecución front/back), permite a los desarrolladores adaptarse más rápidamente a cualquier proyecto OOP.
Testabilidad: se facilita la creación de tests unitarios y de integración.
Extensibilidad: se pueden realizar cambios, reemplazar componentes, ... sin afectar a todo el código.
Lo resumiría en simplicidad.
Hasta aquí llega lo que quería compartir con vosotros, así que termino el tostón por hoy, si habéis llegado hasta aquí ya me hacéis feliz ;)
Posted on January 26, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.