Aprende cómo la arquitectura hexagonal puede optimizar tus aplicaciones Angular, mejorando la escalabilidad, mantenibilidad y la separación de responsabilidades.

¿Qué es la arquitectura hexagonal y por qué deberías prestarle atención?

La arquitectura hexagonal es un patrón de diseño de software que se centra en separar la lógica de negocio de la interfaz de usuario mediante interfaces y puertos. El resultado es un núcleo de aplicación fácil de probar y reutilizar, lo que se traduce en un sistema más mantenible, escalable y flexible.

Gracias a estas características, esta arquitectura nos permite desarrollar aplicaciones más sólidas, adaptables y escalables, capaces de enfrentarse a cambios en las necesidades del negocio e integrarse sin problemas con diferentes tecnologías y sistemas.

Fundamentos de la arquitectura hexagonal

Antes de profundizar en cómo aplicar la arquitectura hexagonal en un proyecto Angular, es importante comprender algunos conceptos fundamentales. La arquitectura hexagonal, también conocida como Ports and Adapters, fue creada por Alistair Cockburn con la intención de tener una arquitectura que permitiera separar la lógica de negocio de los detalles técnicos. Es decir, que la lógica de negocio sea completamente agnóstica a la procedencia de los datos, ya sean de una base de datos, una API web o cualquier otra fuente de datos.

Esta independencia nos ofrece un montón de beneficios, entre ellos que cualquier modificación o mejora en estos detalles no tiene por qué afectar a la lógica de negocio. Y eso no es todo, también facilita mucho las pruebas, porque el núcleo del sistema puede ser testeado sin tener que depender de bases de datos externas o APIs web.

En el mundo de la arquitectura hexagonal, cada detalle de implementación es considerado como un plugin del núcleo de la aplicación, que interactúa con el mundo exterior a través de abstracciones definidas por puertos. De esta forma, el núcleo se mantiene limpio y libre de "contaminación".

Para llevar a cabo la arquitectura hexagonal, necesitarás familiarizarte con tres componentes fundamentales: dominio, puertos y adaptadores.

Elementos de la arquitectura hexagonal

Aplicando la arquitectura hexagonal en un proyecto Angular

A continuación, veremos un ejemplo de cómo aplicar la arquitectura hexagonal en un proyecto Angular. Para ello, crearemos una aplicación de lista de tareas para mostrar cómo aplicar los conceptos mencionados anteriormente. Las acciones que realizaremos serán las siguientes:

Core

Lo primero que haremos es crear un directorio core para almacenar la lógica del negocio y las interfaces de dominio.

├── src
│   ├── app                   
│   │   ├── core
│   │   │    ├── models
│   │   │    │    ├── task.model.ts
│   │   │    ├── repositories
│   │   │    │    ├── task.repository.ts

A su vez, dentro de ella, crearemos otra carpeta models para almacenar los modelos de nuestra aplicación. En este caso, añadiremos un archivo llamado task.model.ts que contendrá la definición del modelo Task. Este modelo define la estructura de datos de una tarea en la aplicación y se usa en la capa de dominio (core) para gestionar las tareas.

task.model.ts

export class Task {
 constructor(
   public id: number,
   public description: string,
   public completed: boolean
 ) {}
}

Luego, crearemos una subcarpeta llamada repositories, dentro de core, para guardar los repositorios de nuestra aplicación. Ahí, también añadiremos un archivo task.repository.ts con la definición de la interfaz TaskRepository, que contendrá los métodos para interactuar con la fuente de datos de las tareas, por ejemplo, una API. Así, de esta manera, la capa de dominio puede comunicarse con la fuente de datos sin depender de una implementación específica.

task.repository.ts

import { Observable } from 'rxjs';

import { Task } from '../models/task.model';

export abstract class TaskRepository {
 abstract getTasks(): Observable<Task[]>;
 abstract addTask(task: Task): Observable<Task>;
 abstract updateTask(task: Task): Observable<void>;
 abstract deleteTask(id: number): Observable<void>;
}

Infraestructura

A continuación, crearemos un directorio infrastructure para almacenar las implementaciones de los adaptadores y servicios externos.

├── src
│   ├── app                   
│   │   ├── infrastructure
│   │   │    ├── dto
│   │   │    │    ├── task.dto.ts
│   │   │    ├── repositories
│   │   │    │    ├── task.repository.impl.ts
│   │   │    ├── services
│   │   │    │    ├── task
│   │   │    │    │    ├── task.service.ts

Luego crearemos una subcarpeta llamada dto para guardar los objetos DTO (Data Transfer Object), que facilitan el intercambio de datos entre las capas de infraestructura y dominio, simplificando el manejo de datos en la aplicación. En este caso, añadiremos un archivo llamado task.dto.ts con la definición de TaskDTO, representando cómo se ven los datos de una tarea en la API.

task.dto.ts

export interface TaskDTO {
 id: number;
 description: string;
 completed: boolean;
}

También añadiremos una carpeta llamada services para guardar los servicios que se comunican con la API. En nuestro ejemplo, crearemos task.service.ts, que contiene el servicio TaskService, responsable de la lógica de tareas en la aplicación. Este servicio utiliza HttpClient para interactuar directamente con la API y, en nuestro caso, lleva a cabo operaciones de mapeo utilizando mapeadores que veremos más adelante.

task.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { TaskMapper } from '@core/mappers/task.mapper';
import { Task } from '@core/models/task.model';
import { TaskDTO } from '@infraestructure/dto/task.dto';
import { Observable, map } from 'rxjs';

@Injectable({
 providedIn: 'root',
})
export class TaskService {
 private apiUrl = 'http://localhost:3001/tareas';

 constructor(private http: HttpClient) {}

 getTasks(): Observable<Task[]> {
   return this.http
     .get<TaskDTO[]>(this.apiUrl)
     .pipe(map((apiTasks) => apiTasks.map(TaskMapper.fromApiToDomain)));
 }

 addTask(task: Task): Observable<Task> {
   const apiTask = TaskMapper.fromDomainToApi(task);
   return this.http
     .post<TaskDTO>(this.apiUrl, apiTask)
     .pipe(map(TaskMapper.fromApiToDomain));
 }

 updateTask(task: Task): Observable<void> {
   const url = `${this.apiUrl}/${task.id}`;
   return this.http.put<void>(url, task);
 }

 deleteTask(id: number): Observable<void> {
   const url = `${this.apiUrl}/${id}`;
   return this.http.delete<void>(url);
 }
}

Por último, crearemos una carpeta repositories dentro de infrastructure, para guardar las implementaciones concretas de las interfaces de dominio. Además, en esta nueva carpeta, añadiremos un archivo llamado task.repository.impl.ts que contendrá la implementación específica de la interfaz TaskRepository para interactuar con el servicio TaskService. Al implementar los métodos de la interfaz TaskRepository, estaremos delegando las acciones a los métodos correspondientes del servicio TaskService, haciendo más sencilla y fluida la comunicación entre la aplicación y el backend.

task.repository.impl.ts

import { Injectable } from '@angular/core';
import { Task } from '@core/models/task.model';
import { TaskRepository } from '@core/repositories/task.repository';
import { TaskService } from '@infraestructure/services/task/task.service';
import { Observable } from 'rxjs';

@Injectable({
 providedIn: 'root',
})
export class TaskRepositoryImpl implements TaskRepository {
 constructor(private taskService: TaskService) {}

 getTasks(): Observable<Task[]> {
   return this.taskService.getTasks();
 }

 addTask(task: Task): Observable<Task> {
   return this.taskService.addTask(task);
 }

 updateTask(task: Task): Observable<void> {
   return this.taskService.updateTask(task);
 }

 deleteTask(id: number): Observable<void> {
   return this.taskService.deleteTask(id);
 }
}

Mappers

Además, crearemos una carpeta llamada mappers dentro de core, donde guardaremos las implementaciones de los mapeadores. Estos mapeadores nos ayudarán a convertir y manejar datos entre la capa de dominio y los adaptadores o servicios externos de una manera más sencilla.

├── src
│   ├── app                   
│   │   ├── core
│   │   │    ├── mappers
│   │   │    │    ├── task.mapper.ts

También incluiremos un archivo llamado task.mapper.ts que contiene las implementaciones de los mapeadores encargados de convertir objetos TaskDTO a Task de dominio y viceversa. Este archivo facilita el manejo de datos entre las capas de dominio e infraestructura.

task.mapper.ts

import { TaskDTO } from '@infraestructure/dto/task.dto';

import { Task } from '../models/task.model';

export class TaskMapper {
 static fromApiToDomain(apiTask: TaskDTO): Task {
   return {
     id: apiTask.id,
     description: apiTask.description,
     completed: apiTask.completed,
   };
 }

 static fromDomainToApi(domainTask: Task): TaskDTO {
   return {
     id: domainTask.id,
     description: domainTask.description,
     completed: domainTask.completed,
   };
 }
}

Módulo y componente

Finalmente, para mostrar las tareas en una vista, crearemos un nuevo componente que use el repositorio de tareas para obtener las tareas y luego mostrarlas en el HTML del componente.

├── src
│   ├── app                   
│   │   ├── modules
│   │   │    ├── tasks
│   │   │    │    ├── tasks.module.ts
│   │   │    │    ├── tasks-routing.module.ts
│   │   │    │    ├── components
│   │   │    │    │    ├── list-tasks
│   │   │    │    │    │    ├── list-tasks.component.html
│   │   │    │    │    │    ├── list-tasks.component.ts

En el archivo list-tasks.component.ts, inyectaremos el repositorio TaskRepository y así obtendremos las tareas en el método loadTasks cuando se ejecute el ciclo de vida del componente a través del ngOnInit.

list-tasks.component.ts

import { Component, OnInit } from '@angular/core';
import { Task } from '@core/models/task.model';
import { TaskRepository } from '@core/repositories/task.repository';

@Component({
 selector: 'app-list-tasks',
 templateUrl: './list-tasks.component.html',
 styleUrls: ['./list-tasks.component.scss'],
})
export class ListTasksComponent implements OnInit {
 tasks: Task[] = [];

 constructor(private taskRepository: TaskRepository) {}

 ngOnInit() {
   this.loadTasks();
 }

 loadTasks(): void {
   this.taskRepository.getTasks().subscribe({
     next: (tasks: Task[]) => {
       this.tasks = tasks;
     },
     error: (error) => {
       console.error('Error al cargar las tareas:', error);
     },
   });
 }
}

Después, en la vista list-tasks.component.html, crearemos una lista para mostrar las tareas:

list-tasks.component.html

<h2>Lista de tareas</h2>
<ul>
 <li *ngFor="let task of tasks">
   {{ task.description }} - {{ task.completed ? 'Completada' : 'Pendiente' }}
 </li>
</ul>

En este caso, se implementará lazy loading mediante la incorporación del componente ListTasksComponent en un módulo llamado TasksModule. Este módulo se configurará en el archivo app-routing.module.ts, para que, en la ruta principal de la aplicación, se cargue el módulo TasksModule y se muestre el componente ListTasksComponent.

JSONServer

Utilizaremos json-server para simular un API backend en nuestra aplicación de ejemplo. De esta manera, podremos realizar llamadas simuladas al API y probar la funcionalidad de la aplicación sin depender de un servidor real.

Lo primero de todo, añadiremos la dependencia al proyecto con el siguiente comando:

$ npm install json-server --save-dev

Después añadiremos un nuevo script para levantar este ‘servidor de mocks’.

package.json

 "scripts": {
   [...]
   "start:mock": "json-server --watch db.json --port 3001",
   [...]
}

Finalmente, incluiremos un archivo db.json que contendrá las tareas almacenadas para nuestro ejemplo, sirviendo como base de datos simulada en nuestra aplicación.

db.json

{
 "tareas": [
   {
     "id": 1,
     "description": "Tarea 1",
     "completed": false
   },
   {
     "id": 2,
     "description": "Tarea 2",
     "completed": true
   }
 ]
}

Para probar la aplicación, deberás ejecutar los siguientes comandos para iniciar tanto el frontend como el servidor de mocks.

$ npm run start
$ npm run start:mock

Si deseas ver el código completo de este ejemplo de aplicación de lista de tareas utilizando la arquitectura hexagonal en Angular, puedes encontrarlo aquí.

Conclusión

La arquitectura hexagonal es una metodología efectiva y robusta para mejorar la estructura de tus aplicaciones Angular. Al separar la lógica de negocio de la interfaz de usuario y enfocarse en los dominios, puertos y adaptadores, lograrás una aplicación más mantenible, escalable y flexible. Además, si cambia alguna parte de la lógica de negocio o el backend, la vista apenas sufriría cambios debido a que las capas de dominio, junto con los mapeadores, permiten abstraerse de esa lógica y facilitan la adaptación a dichos cambios sin modificar significativamente la interfaz de usuario.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.