Llevando la Arquitectura Hexagonal a Angular: Un Enfoque Escalable para el Desarrollo Frontend

Si estás leyendo esto, es probable que ya estés familiarizado con la arquitectura hexagonal, el conocido enfoque de diseño que ayuda a desacoplar la lógica de negocio central de las dependencias externas. Pero aunque es una metodología muy popular en el desarrollo backend, probablemente no la hayas visto aplicada con tanta frecuencia en el desarrollo frontend.

Introduction

Creo que esto se debe a la naturaleza de frameworks como Angular, donde la lógica de negocio tiende a estar estrechamente acoplada con los mecanismos incorporados de la tecnología, como los componentes, servicios, pipes, directivas y validadores. Como resultado, los frameworks frontend suelen alinearse de forma más natural con patrones como MVVM, MVC o MVP.

En los últimos años, tuve la oportunidad de diseñar la arquitectura de un proyecto en Angular. Al provenir de un entorno más orientado al backend, quería explorar si la arquitectura hexagonal podría encajar en el desarrollo frontend de una forma similar a como ya la habíamos utilizado con éxito en muchos proyectos backend en WATA Factory. Inicialmente, me preocupaba que pudiera ser un enfoque excesivo o que pareciera algo forzado. Sin embargo, con el tiempo, descubrí que tenía más beneficios que inconvenientes.

Una de las principales ventajas fue la organización estructurada de los archivos, que proporcionaba una arquitectura clara y coherente. A pesar del código adicional y las interfaces necesarias, este enfoque hizo que el proyecto fuera más accesible tanto para desarrolladores frontend como backend. La familiaridad con la estructura facilitó la incorporación de nuevos miembros al equipo y la colaboración entre ellos. Como resultado, este estilo arquitectónico se está adoptando ahora en otros proyectos dentro de WATA Factory, donde ha demostrado mejorar la escalabilidad y la capacidad de prueba.

Estructura del Proyecto

Nuestro proyecto está organizado siguiendo un enfoque de segmentación vertical. Esto significa que cada funcionalidad es independiente y contiene todo lo necesario, desde la lógica de negocio hasta la presentación.

Las carpetas principales incluyen:

app/
│── core/                  # Cross-cutting concerns (DI, external libs, etc.)
│── shared/                # Shared logic across features
│    |── domain/           # Shared business logic, domain models, validation
│    |── application/      # Shared application use cases
│    |── infrastructure/   # Shared infrastructure (APIs, repositories, utilities, adapters)
│    |── presentation/     # Shared presentation logic (UI components, directives, pipes)
│── authentication/        # Feature: Logic for Authentication (example)
│    |── domain/           # Feature: Business logic, domain models, validation
│    |── application/      # Feature: Application use cases
│    |── infrastructure/   # Feature: External integrations (APIs, DB, services)
│    |── presentation/     # Feature: UI components, directives, services, styles
│── other-feature/         # Another feature (follows the same structure)
     |── domain/
     |── application/
     |── infrastructure/
     |── presentation/

Carpeta Core (app/core)

Esta carpeta contiene preocupaciones transversales como la configuración de inyección de dependencias y la configuración de bibliotecas externas (por ejemplo, JWT, servicios de traducción).

Inyección de Dependencias (app/core/di)

El sistema de inyección de dependencias de Angular funciona a la perfección con clases abstractas, pero no con interfaces. Aunque prefiero usar interfaces para reforzar contratos estrictos sin lógica compartida, permitiendo así un desacoplamiento completo de la implementación, existe un inconveniente. Usar interfaces requiere más código adicional, concretamente la necesidad de utilizar InjectionToken para integrarlas en el sistema de inyección de dependencias de Angular.

Para configurar esto, usaremos un archivo llamado injection.ts, donde estableceremos implementaciones por defecto usando InjectionToken como parte del patrón de contenedor de inyección de dependencias.

export const AUTH_REPOSITORY_TOKEN = new InjectionToken<AuthRepository>(
	'AuthRepository',
);

export const DI_PROVIDERS = [
	{
		provide: AUTH_REPOSITORY_TOKEN,
		useClass: ApiAuthRepository,
	},
	// other providers
];

Estos proveedores se añaden después en app.config.ts.

Carpeta Shared (app/shared)

Esta carpeta también se estructura para albergar lógica reutilizable entre funcionalidades, manteniendo una separación clara de responsabilidades. Es importante asegurarse de que todo lo que se incluya en la carpeta shared esté alineado con la capa adecuada del dominio, aplicación, infraestructura o presentación.

Carpetas de Funcionalidad (app/authentication)

Cada funcionalidad sigue una segmentación vertical, lo que significa que contiene su propio conjunto de capas. Hay que tener en cuenta que utilizaremos Authentication solo como un ejemplo de funcionalidad. Seguimos la misma estrategia de segmentación vertical para otras funcionalidades, garantizando una arquitectura coherente, mantenible y escalable en todo el proyecto.

Capa de Dominio (app/authentication/domain)

Contiene la lógica de negocio principal, los modelos de dominio y las reglas de validación.

Modelos de Dominio (app/authentication/domain/models)

Los modelos de dominio encapsulan la lógica de validación y el comportamiento específico del dominio:

export class UsernameModel {
 readonly value: string;

  constructor(value: string) {
    if (!value) {
      throw new EmptyUsernameError();
    }
    
    const usernameRegex = /^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$/; 
    
    if (!usernameRegex.test(value)) {
      throw new InvalidUsernameError();
    }

    this.value = value;
  }
}

Excepciones de Dominio (app/authentication/domain/errors)

Este directorio contiene excepciones específicas del dominio que representan violaciones de reglas de negocio.

Interfaces de Repositorio de Dominio (app/authentication/domain/repositories)

Este directorio contiene abstracciones para los repositorios de datos, definiendo los contratos que las implementaciones de los repositorios deben seguir.

export interface AuthRepository {
	loginAttempt(loginModel: LoginModel): Observable<AuthTokenModel>;
}

Capa de Aplicación (app/authentication/application)

Esta capa contiene los servicios de aplicación que orquestan los casos de uso. También integramos aquí las stores de NGRX para la gestión del estado, aunque un análisis más profundo sobre NGRX requeriría un artículo aparte.

Casos de Uso (app/authentication/application/use-cases)

Los casos de uso implementan la lógica de negocio e interactúan con los repositorios. Siguen una interfaz consistente UseCase<S, T>, donde S es el modelo de entrada y T es el modelo de salida:

export class LoginUseCase implements UseCase<LoginModel, UserStatusModel> {
  
	constructor(private authRepository: AuthRepository) {
    }

  	execute(loginModel: LoginModel): Observable<UserStatusModel> {
  		return this.authRepository.loginAttempt(loginModel);
  	}
  
}

La interfaz se define en app/shared/application/use-cases/base/use-case.ts.

import { Observable } from 'rxjs';

export interface UseCase<S, T> {
  execute(params: S): Observable<T>;
}

Capa de Infraestructura (app/authentication/infrastructure)

Gestiona las implementaciones para integraciones externas como llamadas a API, interacciones con bases de datos y otros servicios externos.

Capa de Presentación (app/authentication/presentation)

Esta capa está estrechamente acoplada a Angular e incluye componentes, directivas, servicios y estilos. Los casos de uso se inyectan en los componentes a través de interfaces para mantener la separación con las capas de aplicación e infraestructura.

Conclusión

En WATA Factory, hemos estado utilizando esta arquitectura en nuestros proyectos de Angular, y nos ha aportado una gran cantidad de beneficios. Al estructurar nuestro código con arquitectura hexagonal y segmentación vertical, hemos logrado que nuestras aplicaciones sean más mantenibles, escalables y testeables.

Uno de los mayores beneficios es que nuestra lógica de negocio permanece independiente de Angular y de otras dependencias externas. Esto hace que nuestro código sea modular, reutilizable y mucho más fácil de adaptar cuando los requisitos cambian.