Durante mucho tiempo, el Modelo Vista Controlador parecía ser la arquitectura favorita de los desarrolladores de software. Se utilizaba tanto en el backend como en el código del frontend. Pero con el creciente interés de la comunidad por el Diseño Orientado al Dominio, esta arquitectura ha sido desafiada por su prima, la arquitectura "hexagonal" (o "puertos y adaptadores").
Al igual que MVC, la arquitectura hexagonal utiliza el principio de separación, pero también más abstracción, y el código de dominio es central en su arquitectura.
Actualmente la arquitectura hexagonal se utiliza sobre todo en el código backend, y hay pocos recursos sobre ella en el código frontend, especialmente para Angular.
¿Cómo adaptar la arquitectura hexagonal a Angular? ¿Sería beneficioso? Si también te interesan estas preguntas, deberías leer este artículo.
Un ejemplo completo y funcional
Todas las explicaciones siguientes se basarán en una aplicación de ejemplo que está disponible en Github.
Esta aplicación está basada en el tour de héroes de Angular. Si lanzas la aplicación, la interfaz mostrada es la misma que en el tutorial de Angular, pero hay grandes diferencias en la estructura del código.
Como recordatorio, el principio de esta pequeña app es mostrar una lista de héroes y gestionarlos (crear, eliminar, modificar).
El módulo angular-in-memory-web-api
se utiliza para simular las llamadas a la API externa.
Este es un resumen de la arquitectura de este ejemplo:
Y la organización del código asociado:
Dominio
En la arquitectura hexagonal, todo el código relacionado con el dominio está aislado. La aplicación "tour of heroes" tiene los siguientes propósitos: mostrar una lista de héroes, mostrar los detalles de un héroe específico y mostrar los registros de las acciones realizadas por el usuario. Las clases relacionadas con el dominio son fundamentales para la arquitectura: HeroesDisplayer
, HeoresDetailDisplayer
y MessagesDisplayer.
Puertos
Como puedes imaginar, el código relacionado con el dominio no está solo en nuestra aplicación de héroes. También hay código relacionado con la interfaz de usuario, correspondiente a los componentes de Angular, y llamadas a APIs externas, correspondientes a los servicios de Angular.
En toda arquitectura hexagonal, el código relacionado con el dominio no interactúa directamente con todo este código. En su lugar, se utilizan objetos llamados puertos, que son implementados por clases de interfaz. Esto debilita el acoplamiento entre los elementos de nuestra arquitectura.
En nuestra aplicación de héroes, HeroesDisplayer
y HeoresDetailDisplayer
necesitan interactuar con un servicio externo, que almacena las interacciones relacionadas con los héroes.
Para ello, expondrán un puerto IManageHeroes
. Para cada una de nuestras clases de dominio, queremos hacer un seguimiento de las interacciones de cada usuario. Por eso también tienen un puerto IManageMessages
.
Los usuarios realizan acciones reales en nuestra aplicación a través de interfaces de visualización. Estas interfaces pueden dividirse en varias categorías, según su propósito.
Para asegurar una comparación fiel con la app Angular tour of heroes, deberíamos tener interfaces que muestren a los héroes (una lista de héroes y un tablero), una interfaz que muestre los detalles de los héroes, y una interfaz que muestre los mensajes. Por lo tanto, los puertos relacionados deberían ser respectivamente IDisplayHeroes
, IDisplayHeroDetail
e IDisplayMessages
.
Adaptadores
Ahora que nuestros puertos están definidos, tenemos que conectar adaptadores en ellos. Uno de los beneficios de la arquitectura hexagonal es la facilidad a la hora de cambiar entre adaptadores.
Por ejemplo, el adaptador conectado a IManageHeroes
podría ser un adaptador que llame a una API REST, y podríamos sustituirlo fácilmente por un adaptador que utilice una API GraphQL.
En nuestro caso, queremos que nuestra aplicación sea idéntica a la aplicación Google tour of heroes
. Así que implementamos un servicio angular HeroAdapterService
, que llama a una API web en memoria, y otro, MessageAdapterService
, que almacena los mensajes localmente.
Los adaptadores para los otros tres puertos son adaptadores relacionados con la interfaz de usuario. En nuestra aplicación, serán implementados por componentes de Angular. Como puedes ver, el puerto IDisplayHeroes
está implementado por tres adaptadores. Los detalles estarán disponibles en lo siguiente.
Los adaptadores para los otros tres puertos son adaptadores relacionados con la interfaz de usuario. En nuestra aplicación, serán implementados por componentes de Angular. Como puedes ver, el puerto IDisplayHeroes
está implementado por tres adaptadores. Los detalles estarán disponibles en lo siguiente.
Opciones de implementación
Debido a que la arquitectura hexagonal fue diseñada para aplicaciones de backend, se han hecho algunos arreglos en la implementación del código. Estas elecciones se explican en la siguiente parte.
Objetos relacionados con Angular en el código del dominio
Una buena práctica en la arquitectura hexagonal es mantener el código relacionado con el dominio independiente de cualquier framework, para asegurar que es funcional para cualquier tipo de adaptador. Pero en nuestro código, el dominio es altamente dependiente de los objetos de Angular y rxjs.
De hecho, podemos asumir que no utilizaremos varios frameworks de typescript o JavaScript, para mantener la coherencia de la interfaz. Además, el sistema de inyección de dependencias de Angular es muy útil para lograr el principio de inversión de control. Sin embargo, debería ser posible utilizar promesas de JavaScript en lugar de observables rxjs, pero tendríamos que escribir un montón de código boilerplate en nuestras clases.
Tipo de retorno Observable en los puertos del lado izquierdo
Dado que la lógica detrás del código se maneja en el dominio, uno podría preguntarse por qué devolver objetos Observable en los puertos IDisplayHeroDetail
, IDisplayHeroes
e IDisplayMessages
.
Efectivamente, cada objeto devuelto por los servicios se maneja dentro del código del dominio utilizando métodos de pipe y tap.
Por ejemplo, el resultado de guardar los detalles del héroe devuelto por HeroAdapterService
se gestiona directamente en el HeroDetailDisplayer
:
askHeroNameChange(newHeroName: string): Observable<void> {
[...]
const updatedHero = {id: this.hero.id, name: newHeroName};
return this._heroesManager.updateHero(updatedHero).pipe(
tap(_ => this._messagesManager.add(`updated hero id=${this.hero ? this.hero.id : 0}`)),
catchError(this._errorHandler.handleError<any>(`updateHero id=${this.hero.id}`, this.hero)),
map(hero => {if(this.hero){this.hero.name = hero.name}})
);
}
Aun así, devolver un observable vacío desde el método askHeroNameChange
es interesante si pretendemos que los adaptadores de la interfaz sepan cuándo se cargan los datos.
Por ejemplo, cuando los cambios en los detalles del héroe son efectivos, podemos volver a la página anterior:
changeName(newName: string): void {
this.heroDetailDisplayer.askHeroNameChange(newName).pipe(
finalize(() => this.goBack())
).subscribe();
}
El inconveniente de esta elección de implementación es la necesidad de suscribirse a cada llamada de función de dominio dentro de los adaptadores del lado izquierdo:
this.heroesDisplayer.askHeroesList().subscribe();
Clase HeroesDisplayer instanciada dos veces
En nuestra aplicación, la inyección de dependencia se maneja en app.module.ts
Utilizamos tokens de inyección para hacer accesibles las clases del dominio dentro de los componentes de Angular.
Por ejemplo, la inyección de IDisplayHeroDetail
en el componente HeroDetail
se hace de esta manera:
import HeroDetailDisplayer from '../domain/hero-detail-displayer';
providers: [
[...]
{provide: 'IDisplayHeroDetail', useClass: HeroDetailDisplayer},
[...]
}
Establece la instancia HeroesDetailDisplayer
como una implementación de IDisplayHeroDetail
import IDisplayHeroDetail from 'src/app/domain/ports/i-display-hero-detail';
export class HeroDetailComponent implements OnInit {
constructor(
@Inject('IDisplayHeroDetail') public heroDetailDisplayer: IDisplayHeroDetail,
[...]
) {}
}
Inyecta HeroDetailDisplayer
dentro de HeroDetailComponent
Sin embargo, hay una sutileza en alguna parte del código: se generan dos tokens de inyección diferentes para la clase HeroesDisplayer
. Además, HeroesComponent
y DashboardComponent
comparten el mismo token de inyección, mientras que el componente HeroSearchComponent
utiliza otro token.
import HeroesDisplayer from '../domain/heroes-displayer';
providers: [
// Used in HeroesComponent and in DashboardComponent
{provide: 'IDisplayHeroes', useClass: HeroesDisplayer},
// Used in HeroSearchComponent
{provide: 'IDisplayHeroesSearch', useClass: HeroesDisplayer},
]
Esto es porque HeroesComponent
y DashboardComponent
pueden compartir la misma instancia de HeroesDisplayer
: muestran la misma lista de héroes.
Por otro lado, si HeroSearchComponent
tuviera esta misma instancia, cada búsqueda afectaría a los héroes mostrados, ya que el atributo heroes es modificado por el método askHeroesFiltered
en HeroesDisplayer
.
Compartir el mismo token para los tres componentes cambiaría el comportamiento de nuestra aplicación:
Beneficios de la arquitectura hexagonal en Angular
La esencia principal de la arquitectura hexagonal consiste en tener adaptadores intercambiables que permiten que nuestra app sea manejada igualmente por un humano, un sistema o por pruebas.
En nuestra app, estamos muy ligados al framework de Angular, lo que significa que no sacamos todo el provecho a esta ventaja de la arquitectura. Sin embargo, he encontrado algunas ideas prometedoras al experimentarlo en el código del frontend.
Capa de presentación desacoplada, capa central y llamadas a servicios externos
El código del dominio, correspondiente a nuestra capa central, está claramente separado del adaptador de la interfaz, es decir, de la capa de presentación, mediante puertos. Gracias a estos mismos puertos, se reduce el riesgo de añadir código no deseado en las llamadas a servicios externos. Toda la lógica del núcleo se maneja en clases de dominio.
constructor(
@Inject('IDisplayHeroes') public heroesDisplayer: IDisplayHeroes
) { }
Importa la clase de dominio, correspondiente a la capa de código
<li *ngFor="let hero of heroesDisplayer.heroes">
[...]
</li>
Utiliza la información de los héroes manejada por el código del dominio dentro de la vista, correspondiente a la capa de presentación.
Factorización del código
Si miras la aplicación original de tour of heroes, el propósito principal del HeroesComponent
, del HeroSearchComponent
y del DashboardComponent
son muy cercanos.
Todos ellos muestran la lista de héroes, pero las posibles interacciones difieren según los componentes. Por lo tanto, el código relacionado con el núcleo, que mapea las devoluciones de los servicios a la información mostrada, debe ser factorizado.
En nuestro código, el código relacionado con el dominio de los tres componentes está factorizado: aprovechamos la reutilización de puertos hexagonales.
Testing
A veces, las pruebas de Angular pueden ser muy dolorosas, aún más si el código del núcleo se mezcla con el código de presentación dentro de los componentes.
Este código crece a medida que tu aplicación evoluciona. Mantener nuestros componentes de presentación, nuestro código de dominio y nuestros servicios aislados unos de otros hace que las pruebas sean más sencillas. Puedes burlarte fácilmente de otras capas, y centrarte en probar la clase actual.
beforeEach(async () => {
spyIDisplayHeroDetail = jasmine.createSpyObj(
'IDisplayHeroDetail',
['askHeroDetail', 'askHeroNameChange'],
{hero: {name: '', id: 0}}
);
spyIDisplayHeroDetail.askHeroDetail.and.returnValue(of());
spyIDisplayHeroDetail.askHeroNameChange.and.returnValue(of());
[...]
}
Pruebas de visualización de los detalles del héroe: la clase y los métodos del dominio pueden ser burlados fácilmente.
Cuando usar la arquitectura hexagonal en Angular
Aunque no podemos compararla totalmente con el código de backend, la arquitectura hexagonal puede tener algunos grandes beneficios en algunas aplicaciones de frontend. Algunos casos de uso parecen especialmente adaptados a ella.
Aplicaciones basadas en perfiles
Como hemos aislado la capa de presentación, las aplicaciones en las que se utiliza la misma lógica dentro de diferentes interfaces, como las aplicaciones basadas en perfiles, son buenas candidatas para nuestra arquitectura.
La rama del panel de administración ofrece un ejemplo de cómo sería la aplicación si añadiéramos una interfaz de panel de administración.
Esta interfaz, diseñada para los usuarios administradores, les permite realizar todas las acciones administrativas dentro de una única vista: añadir, cambiar, eliminar o buscar héroes. Sólo se añade el AdminPanelComponent
a la app de héroes, sin cambios dentro del código del dominio o de los servicios, mostrando su atributo reutilizable.
Para lanzar la interfaz del administrador, ejecute npm run start
:admin en la rama admin-panel
.
Aplicaciones que llaman a múltiples servicios externos
La arquitectura hexagonal de Angular también se adapta si tienes que contactar con varios servicios externos con el mismo propósito. Una vez más, la reutilización del código del dominio se simplifica.
Conclusión
Esta pequeña aplicación muestra que es posible adaptar la arquitectura hexagonal a una aplicación Angular. Algunos problemas, que no han sido planteados por el recorrido de la app tutorial de héroes, pueden ser resueltos con ella.
Fuente