Puertos en arquitectura hexagonal con Node.js

Mauricio Eraso
Pragma
Published in
6 min readJan 18, 2024

La arquitectura hexagonal, también conocida como arquitectura de puertos y adaptadores ha alcanzado un destacado reconocimiento en el ámbito de la arquitectura de software. Su popularidad se debe a su versatilidad en diversos casos de uso, así como a su capacidad para proporcionar herramientas que fomentan el desarrollo de software de manera mantenible, limpia y especialmente reutilizable.

A pesar de su amplia aceptación, existe una carencia significativa de información detallada sobre sus componentes, en particular, los adaptadores y, como se explorará en este artículo, los puertos. Por lo tanto, nos enfocaremos en desglosar y comprender cada una de estas partes, abordando los siguientes puntos:

  • Qué son los puertos y por qué son importantes en la arquitectura hexagonal.
  • Tipos de puertos.
  • Mostrar cómo los puertos pueden proporcionar una interfaz clara y simple para que los usuarios interactúen con el sistema.

Para este caso, se realizará la explicación a través del entorno de ejecución de Node.js para entender cómo podemos poner los puertos en ambientes reales.

Empecemos…

¿Qué son los puertos ?

Podemos definir los puertos como “interfaces o contratos que definen cómo se deben comportar los componentes de una aplicación”, es decir, son los encargados de separar la lógica de negocios de la infraestructura de una aplicación, con esto se consigue que el código sea más reutilizable, mantenible y fácil de probar para futuros desarrollos.

Puertos y adaptadores en arquitectura hexagonal

Podemos observar como los adaptadores implementan y usan los puertos en las capas externas, como puede ser la infraestructura o interfaces de usuario:

interface IPaymentService {
cashPayment(payment: IPayment): Promise<IPaymentResponse>
}

La importancia de los puertos reside en la necesidad de “independizar” la capa de aplicación (lógica de negocios) de sus dependencias externas, como bases de datos, APIs o terceros, con esto se consiguen varias ventajas al momento de desarrollo de aplicaciones complejas, tales como:

  • Tienen un acoplamiento bajo: los puertos son importantes para dividir la capa de aplicación de dependencias externas, lo que proporciona la independencia de la lógica de negocios de las tecnologías específicas, frameworks u otras herramientas utilizadas en la capa de infraestructura.
  • Facilitan las pruebas: Se pueden crear mocks o stubs basados en las interfaces para facilitar las pruebas unitarias, de integración e incluso pruebas E2E.
  • Simplifican la documentación: Los puertos sirven también como herramienta para la auto documentación del código, es decir, las definiciones de interfaces y contratos proveen de forma explícita dentro del código, como las operaciones, métodos y tipos de datos que son transferidos en cada punto.
  • Mejoran la seguridad: Aplicando validaciones de métodos, o atributos para evitar el mal funcionamiento o brechas de seguridad en las implementaciones, esto ayuda a mantener una arquitectura segura.
  • Permiten la mantenibilidad: se permite a través de los puertos, la flexibilidad y la adaptación a cambios en el código, así, por ejemplo, si se necesita cambiar un gestor de base de datos, cambiar una interfaz de usuario o integrar un servicio externo, solo se deben transformar los adaptadores que implementan los puertos, manteniendo la independencia de la capa de aplicación.

¿Qué tipos de puertos existen?

Según la necesidad dentro del software, se pueden determinar varios tipos de puertos, entre ellos tenemos:

  • Puertos de entrada y salida (I/O): Estos puertos son los que definen las interfaces por las cuales los clientes externos (entrada), se comunican con la capa de dominio y por otra parte, las interfaces por las cuales la capa de aplicación devuelve las respuestas a clientes externos (salida)
  • Puertos de servicios: Este tipo de puertos son usados para definir interfaces para los servicios que están dentro de la capa de dominio, o lógica de negocios. Pueden incluir servicios de autenticación, de procesamiento de pagos o servicios para comunicarse con otros clientes externos
  • Puertos de repositorio: Los puertos de repositorio definen un contrato para el almacenamiento de datos y operaciones de consultas, los cuales pueden tener métodos de consulta, inserciones o recuperación de datos de varias bases de datos.
  • Puertos de notificación: Las interfaces de notificación pueden contemplar las interfaces para enviar notificaciones o eventos para clientes externos, como puede ser, correos, mensajes de texto, webhooks, o incluso mensajes a través de colas.
  • Puertos de Logging y monitoreo: Los cuales definen como los eventos y errores son registrados.

¿ Cómo funcionan ?

Conociendo la definición y cómo se pueden aplicar los puertos en diferentes escenarios, podemos ver la importancia de estos en una arquitectura limpia. Estos actúan como una forma simple y clara de definir interfaces y contratos que ayudan en un equipo de programación a reutilizar y mantener código de una forma más sencilla. A continuación se muestra un escenario de ejemplo para poder observar la implementación de puertos en escenarios reales:

Consideremos el ejemplo de un servicio básico de un restaurante, en el cual se necesita crear un servicio para enviar notificaciones a los clientes de sus pedidos, y el cual estará desarrollado con Node.js y Typescript, para lo cual se definirán los siguientes puertos:

// INotificationInput.ts

interface NotificationInput {
enviarNotificacion(mensaje: string): void;
}

export default NotificationInput;

Creamos el adaptador de entrada, que implementa el controlador, en este caso de una clase restaurante:

// RestauranteController.ts
import NotificationInput from '../ports/NotificationInput';
import NotificationService from '../services/NotificationService';

class RestauranteController implements NotificationInput {
private notificationService: NotificationService;

constructor(NotificationService: NotificationService) {
this.notificationService = NotificationService;
}

enviarNotificacion(mensaje: string): void {
this.notificationService.enviarNotificacion(mensaje);
}
}

export default RestauranteController;

Y ahora definimos el puerto de salida:

//INotificationOutput.ts

interface INotificationOutput {
sendNotification(message: string): void
}

export default INotificationOutput;

Este puerto va a ser el que implemente el servicio para enviar una notificación, a un servicio externo:

// NotificationService.ts
import NotificationOutput from '../ports/NotificationOutput';

class PedidoNotificationService {
private notificationOutput: NotificationOutput;

constructor(NotificationOutput: NotificationOutput) {
this.notificationOutput = NotificationOutput;
}

enviarNotificacion(mensaje: string): void {

// Llamada al puerto de salida para enviar la notificación
this.notificationOutput.enviarNotificacion(mensaje);
}
}

export default PedidoNotificationService;

Para este ejemplo, se pretende enviar el mensaje a través de correo electrónico, por lo tanto creamos el adaptador de salida:

// EmailNotificationAdapter.ts

import NotificationOutputPort from '../ports/NotificationOutput';

class EmailNotificationAdapter implements NotificationOutputPort {
enviarNotificacion(mensaje: string): void {
console.log(`Enviando notificación por correo electrónico: ${mensaje}`);
}
}

export default EmailNotificationAdapter;

Y por último instanciar los adaptadores para enviar una notificación a través de correo electrónico:

// index.ts
import PedidoNotificationService from './services/NotificationService';
import RestauranteController from './controllers/RestauranteController';
import EmailNotificationAdapter from './adapters/EmailNotificationAdapter';


// Instancia del adaptador de salida para correo electrónico
const emailAdapter = new EmailNotificationAdapter();

// Instancia del servicio de aplicación
const pedidoNotificationService = new PedidoNotificationService(emailAdapter);

// Instancia del controlador con el servicio de aplicación
const restauranteController = new RestauranteController(pedidoNotificationService);

// Ejemplo de uso del controlador para enviar una notificación
const mensaje = 'Su pedido ha sido entregado';
restauranteController.enviarNotificacion(mensaje);

Este es un ejemplo básico del uso de puertos en arquitectura limpia, pero ahora revisaremos cómo puede ser útil al momento de escalar nuevas funcionalidades según sea necesario, por ejemplo, si se requiere añadir una nueva funcionalidad para enviar notificaciones a través de SMS, se podría realizar lo siguiente:

Creamos el adaptador:

// SMSNotificationAdapter.ts

import NotificationOutput from "../ports/NotificationOutput";

class SMSNotificationAdapter implements NotificationOutput {
enviarNotificacion(mensaje: string): void {
// Lógica para enviar la notificación por correo electrónico
console.log(`Enviando notificación por mensaje de texto: ${mensaje}`);

}
}

export default SMSNotificationAdapter;

Y lo integramos, reemplazando el servicio de correo electrónico:

// index.ts
import PedidoNotificationService from './services/NotificationService';
import RestauranteController from './controllers/RestauranteController';
import SMSNotificationAdapter from './adapters/SMSNotificationAdapter';


// Instancia del adaptador de salida para SMS
const smsAdapter = new SMSNotificationAdapter();


// Instancia del servicio de aplicación
const pedidoNotificationService = new PedidoNotificationService(emailAdapter);

// Instancia del controlador con el servicio de aplicación
const restauranteController = new RestauranteController(pedidoNotificationService);

// Ejemplo de uso del controlador para enviar una notificación
const mensaje = 'Su pedido ha sido entregado';
restauranteController.enviarNotificacion(mensaje);

Y con este ejemplo podemos observar como podemos hacer la actualización del código con nuevas funcionalidades sin modificar la capa de aplicación, donde esta permanece independiente de los métodos que se puedan crear a futuro.

En conclusión

Como hemos podido observar, los puertos desempeñan un papel crucial en la arquitectura hexagonal, al establecer los contratos fundamentales entre la capa de aplicación y sus adaptadores o implementaciones. Esta función es esencial para garantizar la flexibilidad, escalabilidad y mantenimiento sencillo a medida que la aplicación evoluciona o experimenta cambios. Los puertos actúan como una capa de abstracción que oculta los detalles técnicos, permitiendo que las funcionalidades se adapten eficazmente a las necesidades específicas o casos de uso de la aplicación. En última instancia, esta característica contribuye significativamente a la robustez y adaptabilidad de la arquitectura hexagonal en el desarrollo de software.

--

--