¿Por qué la Arquitectura Hexagonal no es solo un término de moda, sino un salvavidas para tu código?
Imagina que estás construyendo una casa. No empotras los enchufes en las paredes de concreto, ¿verdad? Primero instalas el cableado, haces el trazado y luego conectas cuidadosamente los enchufes. En el desarrollo de software es exactamente igual: tu lógica de negocio (el núcleo de la aplicación) no debe depender de cómo se comuniquen con ella: REST API, base de datos o colas de mensajes.
La Arquitectura Hexagonal (también conocida como "Puertos y Adaptadores", o "Arquitectura Limpia" en la interpretación de Alistair Cockburn) resuelve precisamente este problema. En este artículo, analizaremos cómo implementar esta arquitectura en TypeScript con ejemplos de código reales. Aprenderás a separar la lógica de negocio de la infraestructura, haciendo que el código sea testeable y flexible.
¿Qué es la Arquitectura Hexagonal en términos sencillos?
Imagina un hexágono. En el centro está tu aplicación con la lógica de negocio. En cada cara hay un "puerto" (interfaz) a través del cual el mundo exterior puede comunicarse con el núcleo. Los adaptadores son implementaciones concretas: controladores, repositorios, clientes de servicios externos. Se conectan a los puertos como un enchufe a una toma de corriente.
El principio principal: las dependencias apuntan hacia adentro. El código externo (bases de datos, frameworks) depende del núcleo, y no al revés. Esto permite cambiar la base de datos de PostgreSQL a MongoDB sin tocar ni una línea de la lógica de negocio.
Conceptos principales
- Núcleo (Dominio) — lógica de negocio, entidades, objetos de valor, casos de uso. No tiene dependencias externas.
- Puerto (Port) — interfaz (en TypeScript es una clase abstracta o interface) que define el contrato para el mundo exterior.
- Adaptador (Adapter) — implementación concreta del puerto. Por ejemplo, un repositorio que trabaja con PostgreSQL a través de TypeORM.
- Aplicación (Application) — capa que coordina la ejecución de los casos de uso.
Ejemplo práctico: sistema de gestión de pedidos
Diseñemos un sistema de pedidos simple. Tendremos las entidades Order y Product, el caso de uso CreateOrder, un puerto para el repositorio y un adaptador para PostgreSQL.
Paso 1. Definimos el núcleo (Capa de dominio)
Creemos la carpeta domain/. Aquí viven las entidades y las interfaces para los puertos. ¡Sin dependencias de frameworks!
// domain/order.entity.tsexport class Order { constructor( public readonly id: string, public items: OrderItem[], public status: 'pending' | 'confirmed' | 'shipped', public total: number ) {}
public addItem(productId: string, quantity: number, price: number): void { this.items.push({ productId, quantity, price }); this.total = this.calculateTotal(); }
private calculateTotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); }}
export interface OrderItem { productId: string; quantity: number; price: number;}Ahora definimos el puerto para el repositorio:
// domain/ports/order.repository.interface.tsimport { Order } from '../order.entity';
export interface IOrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>; findAll(): Promise<Order[]>;}Paso 2. Caso de Uso — el corazón de la aplicación
Un Caso de Uso es un escenario de uso. Contiene la lógica de negocio y utiliza los puertos, pero no sabe quién los implementa.
// application/use-cases/create-order.use-case.tsimport { Order } from '../../domain/order.entity';import { IOrderRepository } from '../../domain/ports/order.repository.interface';
export class CreateOrderUseCase { constructor(private readonly orderRepository: IOrderRepository) {}
async execute(use