Arquitectura Hexagonal | Una Guía Práctica para Aplicaciones Modernas
Aprende qué es la arquitectura hexagonal, por qué importa y cómo implementarla. Una guía completa con ejemplos reales para construir software mantenible y testeable.
La arquitectura hexagonal es un patrón de diseño de software que aísla tu lógica de negocio principal de sistemas externos como bases de datos, APIs e interfaces de usuario. La idea es simple: tu código de dominio nunca debe depender de la infraestructura. La infraestructura depende del dominio.
El patrón fue introducido por Alistair Cockburn en 2005. También puedes escucharlo llamado “Ports and Adapters” (Puertos y Adaptadores). Ambos nombres describen el mismo concepto.
Por Qué Existe
La mayoría de aplicaciones empiezan de la misma forma. La lógica de negocio se enreda con consultas a base de datos, handlers HTTP y llamadas a servicios de terceros. Funciona al principio. Luego el código crece, y ocurren tres cosas:
- Las pruebas se vuelven dolorosas. No puedes probar las reglas de negocio sin levantar una base de datos o mockear la mitad del framework.
- Cambiar la infraestructura es arriesgado. Cambiar un proveedor de pagos o migrar de REST a GraphQL significa reescribir lógica de negocio.
- El código es difícil de razonar. Nadie puede distinguir dónde termina el dominio y dónde empieza la infraestructura.
La arquitectura hexagonal resuelve los tres problemas imponiendo una única regla: las dependencias siempre apuntan hacia adentro.
Los Conceptos Fundamentales
Piensa en tu aplicación como tres capas concéntricas.
El Dominio (Centro)
Esta es tu lógica de negocio. Funciones puras, entidades, objetos de valor y servicios de dominio. Tiene cero dependencias de frameworks, bases de datos o bibliotecas externas. Habla su propio lenguaje.
Para un sistema de e-commerce, esta capa contiene conceptos como Order, Product, PricingRule e InventoryPolicy. Estos objetos no saben nada de SQL, HTTP o JSON.
Puertos (Capa Intermedia)
Los puertos son interfaces que definen cómo el mundo exterior interactúa con el dominio. Son contratos, no implementaciones.
Hay dos tipos:
- Puertos entrantes (driving). Definen lo que la aplicación puede hacer. Piensa en ellos como casos de uso. “Realizar un pedido.” “Cancelar una suscripción.” “Generar una factura.”
- Puertos salientes (driven). Definen lo que la aplicación necesita del mundo exterior. “Guardar un pedido.” “Enviar una notificación.” “Obtener el tipo de cambio.”
Los puertos son parte de la capa de dominio. Están escritos en lenguaje de dominio, no en lenguaje de infraestructura. Escribes OrderRepository, no PostgresOrderDAO.
Adaptadores (Capa Externa)
Los adaptadores son las implementaciones concretas que conectan el mundo real con tus puertos.
- Adaptadores entrantes traducen solicitudes externas en llamadas al dominio. Un controlador REST, un resolver GraphQL, un comando CLI, un consumidor de cola de mensajes. Todos estos son adaptadores entrantes.
- Adaptadores salientes implementan las interfaces de los puertos salientes. Un repositorio PostgreSQL, un emisor de email SMTP, una pasarela de pagos Stripe. Todos estos son adaptadores salientes.
La clave: los adaptadores dependen de los puertos. Los puertos nunca dependen de los adaptadores. Esto es lo que hace que todo el patrón funcione.
Un Ejemplo Concreto
Digamos que estás construyendo una funcionalidad de realización de pedidos. Así es como se desglosan las capas.
Dominio (entidades y reglas):
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Puerto entrante (interfaz del caso de uso):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Puertos salientes (lo que necesita el caso de uso):
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Servicio de aplicación (implementa el puerto entrante):
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adaptadores (infraestructura):
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Observa cómo PlaceOrderService nunca menciona PostgreSQL, HTTP ni ningún framework. Trabaja puramente con conceptos de dominio e interfaces de puertos.
La Regla de Dependencia
Esta es la regla más importante. Si no obtienes nada más de este artículo, quédate con esto:
Las dependencias del código fuente siempre deben apuntar hacia adentro, hacia el dominio.
- Los adaptadores dependen de los puertos. Nunca al revés.
- Los puertos dependen de las entidades de dominio. Nunca de los adaptadores.
- El dominio no depende de nada externo.
Esto se aplica mediante inyección de dependencias. Tu raíz de composición (el punto de entrada de la aplicación) conecta todo:
// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
La capa de dominio nunca importa de la capa de adaptadores. Si ves un import desde adapters/ dentro de domain/ o ports/, algo está mal.
Estructura del Proyecto
Una estructura de carpetas limpia hace visibles los límites:
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Cada desarrollador del equipo puede mirar este árbol y entender dónde colocar código nuevo.
Beneficios para las Pruebas
Aquí es donde la arquitectura hexagonal realmente compensa.
Pruebas unitarias del dominio:
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Sin base de datos. Sin servidor HTTP. Sin framework de mocking. Lógica pura, pruebas puras.
Pruebas de casos de uso con adaptadores falsos:
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Pruebas el caso de uso completo sin tocar infraestructura real. Los adaptadores falsos implementan las mismas interfaces de puertos, así que son intercambiables con los reales.
Pruebas de integración solo para adaptadores:
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Cada adaptador tiene su propia prueba de integración. Si el adaptador pasa su prueba, y el caso de uso pasa con adaptadores falsos, todo el sistema funciona.
Cuándo Usar Arquitectura Hexagonal
La arquitectura hexagonal añade estructura e indirección. Eso tiene un coste. Aquí es cuando vale la pena:
- Aplicaciones de larga vida. Si el proyecto se mantendrá durante años, la inversión se amortiza rápidamente.
- Reglas de negocio complejas. Si tu dominio tiene lógica no trivial que necesita probarse exhaustivamente.
- Múltiples puntos de entrada. Si la misma lógica se llama desde una API web, un CLI, una cola de mensajes y un cron job.
- La infraestructura podría cambiar. Si no estás seguro de si mantendrás tu base de datos actual, proveedor de cloud o servicios de terceros.
- Múltiples desarrolladores. Los límites claros reducen los conflictos de merge y hacen las code reviews más rápidas.
Cuándo Saltársela
No todos los proyectos necesitan este nivel de estructura:
- Aplicaciones CRUD simples. Si la app es principalmente lectura y escritura de datos con lógica de negocio mínima, la indirección añade coste sin beneficio.
- Prototipos y MVPs. La velocidad importa más que la arquitectura cuando estás validando una idea. Refactoriza después.
- Scripts y herramientas pequeñas. Una herramienta CLI de 200 líneas no necesita puertos y adaptadores.
La cantidad correcta de arquitectura depende de la complejidad del problema. Empieza simple. Introduce límites hexagonales cuando el dolor de no tenerlos se vuelva real.
Errores Comunes
Filtrar infraestructura al dominio. Si tu entidad Order tiene un decorador @Column o un método toJSON(), la infraestructura se ha filtrado. Mantén tus objetos de dominio limpios.
Crear puertos que replican la infraestructura. Un puerto llamado SqlQuery anula el propósito. Los puertos deben describir lo que el dominio necesita en lenguaje de dominio, no cómo funciona la infraestructura.
Sobre-abstraer. No todo necesita un puerto. Si tienes una función utilitaria que formatea fechas, úsala directamente. Reserva los puertos para los verdaderos límites de infraestructura.
Saltarse la raíz de composición. Sin un lugar claro donde las dependencias se conectan, el patrón se desmorona. Cada dependencia debe inyectarse, no importarse directamente.
Hexagonal vs. Otros Patrones
Quizás te preguntes cómo se relaciona la arquitectura hexagonal con otros patrones conocidos:
- Clean Architecture (Robert C. Martin): Muy similar. Clean Architecture añade más capas (entidades, casos de uso, adaptadores de interfaz, frameworks) pero la idea central es la misma: las dependencias apuntan hacia adentro.
- Onion Architecture (Jeffrey Palermo): También muy similar. Onion Architecture usa anillos concéntricos con el dominio en el centro. La terminología difiere, pero la regla de dependencia es idéntica.
- Arquitectura por Capas (N-tier): El enfoque tradicional donde las capas se apilan verticalmente (presentación, negocio, datos). La diferencia crucial: en la arquitectura por capas, la capa de negocio típicamente depende de la capa de datos. En la arquitectura hexagonal, esa dependencia se invierte.
Los tres patrones modernos (hexagonal, clean, onion) comparten el mismo principio fundamental. El dominio no depende de la infraestructura. Difieren principalmente en convenciones de nomenclatura y el número de capas que prescriben.
Cómo Empezar
Si quieres aplicar arquitectura hexagonal a un proyecto existente, no intentes refactorizar todo a la vez. Empieza con un bounded context o una funcionalidad:
- Identifica la lógica de negocio central en esa funcionalidad. Extráela en funciones puras o clases sin dependencias de framework.
- Define puertos para las dependencias externas que usa esa funcionalidad. Crea interfaces en lenguaje de dominio.
- Mueve el código de infraestructura existente a clases adaptadoras que implementen esas interfaces.
- Conecta todo en una raíz de composición usando inyección de dependencias.
- Escribe pruebas para el dominio y los casos de uso usando adaptadores falsos.
Repite para la siguiente funcionalidad. Con el tiempo, los límites hexagonales se expandirán por el código de forma natural.
Reflexiones Finales
La arquitectura hexagonal no trata de seguir una plantilla rígida. Trata de una idea simple: proteger tu lógica de negocio del caos del mundo exterior. Las bases de datos cambian. Los frameworks pasan de moda. Las APIs se deprecan. Tus reglas de negocio deberían sobrevivir a todo eso.
El patrón funciona porque se alinea con cómo el software realmente evoluciona. Los requisitos cambian constantemente. La infraestructura cambia periódicamente. Pero las reglas centrales de tu negocio tienden a ser la parte más estable del sistema. Construye alrededor de esa estabilidad.
Si estás construyendo un producto que necesita durar, la arquitectura hexagonal es una de las mejores inversiones que puedes hacer en tu código.
¿Necesitas ayuda diseñando o refactorizando la arquitectura de tu aplicación? Contáctanos. Ayudamos a equipos a construir software que se mantiene mantenible a medida que crece.