Arquitectura Hexagonal

La arquitectura hexagonal es ampliamente utilizada y con razón. Esta arquitectura defiende la "separación de preocupaciones", lo que significa que la lógica de negocios se divide en diferentes servicios o hexágonos, los cuales se comunican entre sí a través de adaptadores a los recursos que necesitan para satisfacer las mismas.

Hexágono y sus actores

Se le llama hexagonal porque su forma se asemeja a un hexágono con una línea vertical que lo divide por la mitad. La mitad izquierda se refiere a los actores primarios, quienes llevan a cabo la acción inicial que da comienzo al funcionamiento del hexágono. Estos actores no se comunican directamente con el servicio, sino que utilizan un adaptador. La parte derecha representa a los actores secundarios, quienes proveen los recursos necesarios para que el hexágono pueda ejecutar la lógica interna.

Los adaptadores son piezas clave en la arquitectura hexagonal, ya que se encargan de mediar la comunicación entre dos entidades para que estas puedan interactuar de forma cómoda. Por ejemplo, en un servicio que provee información de los usuarios se utiliza el término "username" para identificar el nombre del usuario, mientras que en otro servicio se utiliza el término "userIdentifier" para la misma acción. Aquí es donde el adaptador interviene para realizar una serie de transformaciones y que la información sea utilizada de la forma más conveniente para cada entidad. En definitiva, los adaptadores facilitan la integración de los diferentes componentes del sistema y la interoperabilidad entre ellos.

Cuando un adaptador se comunica con un actor primario se le llama driver, mientras que cuando se comunica con un recurso necesario se le llama driven. Los drivens se encuentran a la derecha de la línea vertical en la imagen hexagonal y es importante destacar que también pueden representar a otros servicios. En este caso, la comunicación entre servicios debe ser a través de los adaptadores correspondientes, los drivers para el servicio que otorga el recurso y los drivens para el servicio que lo solicita. Así, un servicio puede actuar como actor primario de otro servicio.

Puertos y recursos

El siguiente concepto importante a comprender es el de los puertos. Estos indican las limitaciones que tienen tanto nuestro servicio como los adaptadores, y representan las diferentes funcionalidades que deben proporcionar a los actores primarios y secundarios para satisfacer las solicitudes y proveer los recursos necesarios.

Tipos de lógica en un servicio

Para comprender las diferentes tipos de lógica que se pueden encontrar dentro de un servicio, es útil distinguir entre la lógica de negocio, la de organización y los casos de uso. Un ejemplo que ilustra esta distinción es una aplicación que administra cuentas bancarias de usuarios y que debe permitir el registro de usuarios mayores de 18 años.

Ejemplo de aplicación: La pizzería en forma de hexágono

Uno de los ejemplos más conocidos es el de una pizzería donde una persona quiere hacer un pedido. Para ello, se fijará en el menú las diferentes opciones, pedirá al cajero su pedido, éste comunicará a la cocina el encargo, la cocina realizará la serie de procedimientos necesarios para cumplir con el requisito y devolverá el pedido ya completo al cajero para que éste se entregue al comprador. Si pensamos detenidamente en cada una de las entidades del ejemplo, podremos encontrar que el comprador es el actor principal, el menú con las diferentes opciones es el puerto, el cajero es el adaptador y la cocina es nuestro servicio.

El consumidor pedirá un producto viendo el menú y solo podrá pedir lo que vea en el mismo. A su vez, ese mismo pedido que de cara al público puede tener un nombre llamativo, seguramente para la cocina se llame de una manera más simplificada para aumentar la eficiencia del proceso. El cajero conoce esta nomenclatura y es el encargado de poder gestionar una correcta comunicación entre el consumidor y la cocina. Lo que para uno es una pizza margarita, para otro es la número 53.

Una parte que no vemos es que la cocina en sí necesita recursos para poder completar el pedido. Esto quiere decir que tanto el queso, el tomate y el resto de ingredientes deben ser solicitados para poder gestionar las órdenes. Para esto, seguramente hay un encargado que se encuentre entre el restaurante y un proveedor de materia prima. Y nuevamente, lo que para uno es tomate, para otro es el producto ABC. Aquí vemos el claro ejemplo de un actor secundario, el proveedor, que se comunica por medio de un intermediario, el encargado, para proveer los recursos necesarios a nuestro hexágono.

Pasos recomendados para trabajar en arquitectura hexagonal con ejemplo de aplicación

Para trabajar en arquitectura hexagonal, es fundamental seguir un proceso metódico y cuidadoso. En primer lugar, es crucial leer detenidamente los requerimientos para poder pensar en la mejor forma de resolverlos. Es esencial reconocer la lógica de negocios y los casos de uso necesarios para satisfacerla. Como recordarás de la explicación anterior, esto es fundamental para asegurarnos de que estamos cubriendo todas las necesidades.

Una vez que hayamos identificado los requisitos y los recursos necesarios, es momento de crear nuestros puertos. Para hacerlo, debemos reconocer los métodos esenciales que deben estar disponibles para nuestros actores primarios y secundarios. Esto nos permitirá controlar los accesos y salidas al servicio. Por convención, los nombres de los puertos deben comenzar con la palabra "For", seguida de la acción que deben realizar. Por ejemplo, si necesitamos un puerto para realizar una acción de registro, podríamos llamarlo "ForRegistering". También podemos reducir la cantidad de puertos si asociamos diferentes acciones relacionadas, como "ForAuthenticating", que proveerá las acciones de registro y login.

Siguiendo estos pasos y manteniendo una atención cuidadosa a los detalles, podremos trabajar de forma efectiva en arquitectura hexagonal y lograr resultados óptimos en nuestros proyectos.

A continuación, debemos crear nuestros adaptadores driver y driven. Para ello, recomiendo utilizar como nombre del adaptador el realizador de la acción, seguido de la acción en sí. Por ejemplo, en nuestro caso, podríamos llamarlos Registerer o Authenticator, respectivamente.

Los adaptadores, en primer lugar, deben ser del tipo stub y proporcionar información controlada que pueda utilizarse para satisfacer la lógica de negocios y los tests de la misma. De esta manera, podremos cerrar nuestro hexágono y dejarlo listo para la implementación.

Con nuestros adaptadores completos, procedemos a utilizar TDD (Desarrollo Guiado por Pruebas, por sus siglas en inglés). Crearemos los tests necesarios para cumplir con los casos de uso, de manera que se pueda comprobar el correcto funcionamiento de la lógica al implementarla.

Nuestro servicio debe disponer de las entidades necesarias para el tipado, satisfacer los métodos proporcionados en los puertos primarios y cumplir con los casos de uso. El servicio es responsable de recibir una solicitud, buscar los recursos necesarios mediante los adaptadores secundarios y utilizarlos para cumplir con los casos de uso y, por lo tanto, con la lógica de negocios.

Ejemplo de código siguiendo la temática de aplicación bancaria:

// Clase que representa el hexágono
export class BankAccountService {
  private bankAccountPort: BankAccountPort;

  constructor(bankAccountPort: BankAccountPort) {
    this.bankAccountPort = bankAccountPort;
  }

  /**
   * Método para crear una nueva cuenta bancaria.
   * @param name - El nombre del titular de la cuenta.
   * @param age - La edad del titular de la cuenta.
   * @throws AgeNotAllowedException si la edad no es permitida.
   */
  public createBankAccount(name: string, age: number): void | AgeNotAllowedException {
    if (age >= 18) {
      const bankAccount = new BankAccount(name, age);
      this.bankAccountPort.saveBankAccount(bankAccount);
    } else {
      throw new AgeNotAllowedException("La edad mínima para crear una cuenta bancaria es de 18 años.");
    }
  }
}

// Archivo bank-account-port.ts
// Interfaz que define el puerto para acceder a la base de datos de cuentas bancarias
export interface BankAccountPort {
  saveBankAccount(bankAccount: BankAccount): void;
}

// Archivo bank-account.ts
// Clase que representa la entidad de una cuenta bancaria
export class BankAccount {
  private name: string;
  private age: number;

  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }

  public getName(): string {
    return this.name;
  }

  public getAge(): number {
    return this.age;
  }
}

// Archivo age-not-allowed-exception.ts
// Excepción personalizada para cuando la edad no está permitida
export class AgeNotAllowedException extends Error {
  constructor(message: string) {
    super(message);
    this.name = "AgeNotAllowedException";
  }
}

// Archivo bank-account-repository.ts
// Clase que implementa el puerto para acceder a la base de datos de cuentas bancarias
export class BankAccountRepository implements BankAccountPort {
  private bankAccounts: BankAccount[] = [];

  public saveBankAccount(bankAccount: BankAccount): void {
    this.bankAccounts.push(bankAccount);
  }
}

// Archivo bank-account-controller.ts
// Clase que representa el driver para la creación de cuentas bancarias
import { BankAccountService } from "./bank-account-service";
import { AgeNotAllowedException } from "./age-not-allowed-exception";

export class BankAccountController {
  private bankAccountService: BankAccountService;

  constructor(bankAccountService: BankAccountService) {
    this.bankAccountService = bankAccountService;
  }

  /**
   * Método para crear una nueva cuenta bancaria.
   * @param name - El nombre del titular de la cuenta.
   * @param age - La edad del titular de la cuenta.
   */
  public createBankAccount(name: string, age: number): void {
    try {
      this.bankAccountService.createBankAccount(name, age);
      console.log("La cuenta bancaria se ha creado correctamente.");
    } catch (e) {
      if (e instanceof AgeNotAllowedException) {
        console.log(e.message);
      } else {
        console.log("Ha ocurrido un error al crear la cuenta bancaria.");
      }
    }
  }
}

// Archivo main.ts
// Ejemplo de uso
import { BankAccountRepository } from "./bank-account-repository";
import { BankAccountService } from "./bank-account-service";
import { BankAccountController } from "./bank-account-controller";

const bankAccountPort = new BankAccountRepository();
const bankAccountService = new BankAccountService(bankAccountPort);
const bankAccountController = new BankAccountController(bankAccountService);

bankAccountController.createBankAccount("John Doe", 20);
bankAccountController.createBankAccount("Jane Doe", 16);