Arquitectura Hexagonal: Más allá de las capas tradicionales (y del caos organizado)
🎯 Introducción: Separar el negocio del circo tecnológico
La Arquitectura Hexagonal —también conocida como Ports and Adapters— no es solo otro nombre fancy para decir “capas”. Es un cambio de mentalidad sobre cómo estructuramos aplicaciones que tienen que sobrevivir años, múltiples frameworks y varios CTOs.
Propuesta por Alistair Cockburn, busca resolver un problema recurrente: nuestra lógica de negocio termina contaminada por detalles de infraestructura: controladores HTTP, ORM, drivers de base de datos, SDKs de terceros, sistemas de mensajería, etc.
Problema típico en arquitecturas “clásicas”
- Controladores que mezclan validaciones, SQL y reglas de negocio.
- Casos de uso que dependen de frameworks específicos.
- Tests que necesitan levantar base de datos, cola de mensajes y media nube de AWS.
- Refactors que dan más miedo que un
rm -rf /sin querer.
La arquitectura hexagonal propone una idea simple pero poderosa:
🧭 Diagrama conceptual 1: vista 10.000 ft
Hexágono y sus alrededores
El dominio y los casos de uso no saben si los llama REST o GraphQL, ni si los datos van a Postgres, Mongo o un CSV con fe.
🏗️ Conceptos fundamentales
El “hexágono”: núcleo de la aplicación
interface ApplicationCore {
domain: DomainModel; // Entidades, value objects, reglas de negocio
application: UseCases; // Casos de uso, orquestación
ports: Contracts; // Interfaces (entrada/salida)
}
Podemos descomponerlo en tres grandes piezas:
- Dominio: el corazón del negocio, sin dependencia de frameworks.
- Puertos: contratos que definen qué necesita o expone el sistema.
- Adaptadores: implementaciones concretas de esos contratos.
Diagrama conceptual 2: mental model rápido
(Infraestructura, frameworks)"] --> Puertos["Puertos
(Contratos)"] --> Dominio["Dominio
(Reglas de negocio)"] classDef core fill:#0f172a,stroke:#38bdf8,color:#e5e7eb,stroke-width:1px; class Dominio core;
Regla de oro: los adaptadores dependen de los puertos; los puertos pertenecen al dominio o a la capa de aplicación. El dominio nunca conoce a sus adaptadores.
⚡ Implementación práctica: sistema de gestión de pedidos
Supongamos un e-commerce con pedidos, productos, pagos y notificaciones. El objetivo no es “conectar endpoints”, sino modelar un dominio que sobreviva a los cambios de la infraestructura.
1. Estructura de proyecto
src/
├── domain/ # Núcleo del negocio
│ ├── entities/
│ ├── value-objects/
│ ├── repositories/ # Puertos de salida
│ └── services/
├── application/ # Casos de uso
│ ├── use-cases/
│ ├── ports/ # Puertos de entrada
│ └── dtos/
├── infrastructure/ # Adaptadores
│ ├── web/ # REST/GraphQL, controllers
│ ├── persistence/ # Repositorios concretos
│ └── messaging/ # Eventos, colas, brokers
└── main.ts # Wiring / composición
Diagrama conceptual 3: flujo de una petición
(HTTP / REST / GraphQL)"] --> Controller["Controller / Handler
(Web adapter)"] --> InPort["Puerto de entrada
(Application Port)"] --> UseCase["Caso de uso"] --> Domain["Entidades + Value Objects
(Dominio)"] --> OutPorts["Puertos de salida"] --> OutAdapters["Adaptadores de salida
(ORM / SDK / Mensajería)"] classDef core fill:#0f172a,stroke:#22c55e,color:#e5e7eb,stroke-width:1px; class UseCase,Domain core;
El caso de uso y el dominio están en el centro del flujo. Lo demás son detalles intercambiables.
🧠 Dominio: donde vive la inteligencia
En el dominio no hay HTTP, no hay SQL y no hay JSON. Solo reglas de negocio.
Por ejemplo, una entidad Order que sabe validarse, calcular totales,
gestionar estados e invariantes.
export interface OrderItem {
productId: string;
quantity: number;
unitPrice: number;
discount: number;
}
export enum OrderStatus {
PENDING = 'pending',
PAID = 'paid',
SHIPPED = 'shipped',
CANCELLED = 'cancelled'
}
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
public items: OrderItem[],
public status: OrderStatus = OrderStatus.PENDING,
public readonly createdAt: Date = new Date(),
public updatedAt: Date = new Date()
) {}
addItem(productId: string, quantity: number, unitPrice: number): void {
const existing = this.items.find(i => i.productId === productId);
if (existing) {
existing.quantity += quantity;
} else {
this.items.push({ productId, quantity, unitPrice, discount: 0 });
}
this.updatedAt = new Date();
}
calculateTotal(): number {
return this.items.reduce((total, item) =>
total + item.unitPrice * item.quantity * (1 - item.discount),
0);
}
markAsPaid(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error('Solo órdenes pendientes pueden ser marcadas como pagadas');
}
this.status = OrderStatus.PAID;
this.updatedAt = new Date();
}
validate(): void {
if (this.items.length === 0) {
throw new Error('La orden debe tener al menos un item');
}
if (this.items.some(i => i.quantity <= 0)) {
throw new Error('Las cantidades deben ser positivas');
}
}
}
El dominio expresa reglas del negocio en código, no en comentarios o tickets perdidos en Jira.
🧩 Value Objects: inmutabilidad y validación embebida
Los Value Objects encapsulan conceptos que merecen reglas propias: email, dinero, direcciones, etc. No son “solo strings”.
export class Email {
private constructor(public readonly value: string) {}
static create(email: string): Email {
if (!this.isValid(email)) {
throw new Error('Email inválido');
}
return new Email(email.toLowerCase().trim());
}
private static isValid(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
getDomain(): string {
return this.value.split('@')[1];
}
}
export class Money {
constructor(
public readonly amount: number,
public readonly currency: string = 'USD'
) {
if (amount < 0) throw new Error('El monto no puede ser negativo');
if (!Number.isFinite(amount)) throw new Error('Monto inválido');
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('No se pueden operar diferentes monedas');
}
}
}
🚪 Puertos: contratos de entrada y salida
Puertos de entrada (driving)
Los puertos de entrada exponen lo que la aplicación sabe hacer: casos de uso. No saben si serán invocados por HTTP, CLI, colas o tests.
export interface OrderService {
createOrder(command: CreateOrderCommand): Promise<OrderResponse>;
getOrder(orderId: string): Promise<OrderResponse>;
updateOrderStatus(command: UpdateOrderStatusCommand): Promise<void>;
cancelOrder(orderId: string, reason: string): Promise<void>;
}
Puertos de salida (driven)
Los puertos de salida representan necesidades del dominio: persistencia, consultas, integración con otros sistemas.
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
findByCustomerId(customerId: string): Promise<Order[]>;
findPendingOrders(): Promise<Order[]>;
}
Diagrama conceptual 4: relaciones entre puertos y adaptadores
(OrderService)"] PortIn --> UseCases["Use Cases"] UseCases --> PortOut["Puerto de salida
(OrderRepository, PaymentGateway, ...)"] PortOut --> Infra["Infraestructura
(ORM, HTTP clients, SDKs)"] classDef core fill:#0f172a,stroke:#4f46e5,color:#e5e7eb,stroke-width:1px; class UseCases core;
Los puertos definen el contrato; los adaptadores implementan el detalle. Cambia el adaptador, no el dominio.
🎯 Casos de uso: orquestación del dominio
Los casos de uso coordinan al dominio y a los puertos de salida. Son el “application service layer” típico, pero sin framework pegado.
export class CreateOrderUseCase {
constructor(
private readonly orderRepository: OrderRepository,
private readonly productRepository: ProductRepository,
private readonly paymentGateway: PaymentGateway,
private readonly notificationService: NotificationService,
private readonly eventPublisher: EventPublisher
) {}
async execute(command: CreateOrderCommand): Promise<OrderResponse> {
await this.validateProducts(command.items);
const order = new Order(
generateId(),
command.customerId,
command.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: await this.getProductPrice(item.productId),
discount: 0
}))
);
order.validate();
await this.orderRepository.save(order);
await this.eventPublisher.publish(new OrderCreatedEvent(order));
await this.notificationService.notifyOrderCreated(order);
return this.toResponse(order);
}
// ...métodos privados de validación, mapping, etc.
}
🧱 Adaptadores: conectando con el mundo real
Adaptador web (REST)
@Controller('orders')
export class OrderController {
constructor(
@Inject('OrderService') private readonly orderService: OrderService
) {}
@Post()
async createOrder(@Body() body: CreateOrderRequest): Promise<OrderResponse> {
const command: CreateOrderCommand = {
customerId: body.customerId,
items: body.items
};
return this.orderService.createOrder(command);
}
@Get(':id')
async getOrder(@Param('id') id: string): Promise<OrderResponse> {
const order = await this.orderService.getOrder(id);
if (!order) {
throw new HttpException('Orden no encontrada', HttpStatus.NOT_FOUND);
}
return order;
}
}
Adaptador de persistencia (TypeORM)
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly repository: Repository<OrderEntity>
) {}
async findById(id: string): Promise<Order | null> {
const entity = await this.repository.findOne({
where: { id },
relations: ['items']
});
return entity ? this.toDomain(entity) : null;
}
async save(order: Order): Promise<void> {
const entity = this.toEntity(order);
await this.repository.save(entity);
}
// toDomain / toEntity: mapping entre entidad ORM y entidad de dominio
}
🧪 Testing: cuando la arquitectura paga dividendos
Tests de dominio puro
Como el dominio no depende de frameworks, sus tests son rápidos, deterministas y se pueden ejecutar cientos de veces por minuto.
describe('Order', () => {
it('agrega un item nuevo', () => {
const order = new Order('1', 'customer-1', []);
order.addItem('product-1', 2, 100);
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity).toBe(2);
});
it('calcula el total con descuento', () => {
const order = new Order('1', 'customer-1', [
{ productId: 'p1', quantity: 2, unitPrice: 100, discount: 0.1 }, // 180
{ productId: 'p2', quantity: 1, unitPrice: 50, discount: 0 } // 50
]);
expect(order.calculateTotal()).toBe(230);
});
});
Tests de casos de uso con mocks
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let orderRepo: jest.Mocked<OrderRepository>;
let productRepo: jest.Mocked<ProductRepository>;
beforeEach(() => {
orderRepo = {
findById: jest.fn(),
save: jest.fn(),
findByCustomerId: jest.fn(),
findPendingOrders: jest.fn()
};
productRepo = {
findById: jest.fn(),
isAvailable: jest.fn(),
updateStock: jest.fn()
};
useCase = new CreateOrderUseCase(
orderRepo,
productRepo,
{} as any,
{} as any,
{} as any
);
});
it('crea una orden válida', async () => {
productRepo.findById.mockResolvedValue({ id: 'p1', name: 'Test', price: 100, stock: 10 });
productRepo.isAvailable.mockResolvedValue(true);
const command: CreateOrderCommand = {
customerId: 'c1',
items: [{ productId: 'p1', quantity: 2 }]
};
const result = await useCase.execute(command);
expect(orderRepo.save).toHaveBeenCalled();
expect(result.customerId).toBe('c1');
});
});
🔄 Patrones avanzados: Event Sourcing + CQRS
En dominios donde la trazabilidad es crítica (finanzas, logística, auditorías), la combinación de CQRS y Event Sourcing encaja muy bien con arquitectura hexagonal.
export class OrderEventStoreRepository implements OrderRepository {
constructor(
private readonly eventStore: EventStore,
private readonly snapshotRepository: SnapshotRepository
) {}
async findById(id: string): Promise<Order | null> {
const snapshot = await this.snapshotRepository.getLatestSnapshot(id);
const events = await this.eventStore.getEvents(id, snapshot?.version || 0);
if (snapshot) {
return Order.reconstitute(snapshot.state, events);
} else if (events.length > 0) {
return Order.reconstituteFromHistory(events);
}
return null;
}
async save(order: Order): Promise<void> {
const changes = order.getUncommittedChanges();
await this.eventStore.appendEvents(order.id, changes);
if ((order.version + changes.length) % 100 === 0) {
await this.snapshotRepository.saveSnapshot(
order.id,
order.takeSnapshot(),
order.version + changes.length
);
}
order.markChangesAsCommitted();
}
}
Diagrama conceptual 5: CQRS + Event Sourcing
El modelo de escritura se centra en consistencia y reglas de negocio; el modelo de lectura está optimizado para consultas y reporting.
🏛️ Clean Architecture vs Arquitectura Hexagonal
Ambas comparten el principio fundamental: las dependencias apuntan hacia el dominio. La diferencia está en cómo estructuran visualmente y conceptualmente esas capas.
| Aspecto | Hexagonal | Clean Architecture |
|---|---|---|
| Enfoque | Puertos y adaptadores | Regla de dependencia entre capas |
| Forma visual | Hexágono / núcleo con bordes | Anillos concéntricos |
| Simetría | Entrada y salida tratadas similar | Más énfasis en “hacia adentro” |
| Uso típico | Integraciones fuertes, múltiples interfaces | Proyectos grandes con muchas capas |
📊 Métricas y beneficios medibles
Cuando se aplica bien, la arquitectura hexagonal genera mejoras tangibles (en proyectos reales, no solo en diagramas bonitos).
const improvements = {
testability: {
before: '30% coverage',
after: '85% coverage'
},
deployFrequency: {
before: '1 deployment/week',
after: '5 deployments/day'
},
timeToMarket: {
before: '2 weeks for new features',
after: '2 days for new features'
},
defectRate: {
before: '15% de despliegues con rollback',
after: '2% de despliegues con rollback'
}
};
Diagrama conceptual 6: impacto en el ciclo de vida
Cambios pequeños → Riesgo alto → Pocos deploys → Feedback lento"] Despues["Después:
Cambios pequeños → Riesgo controlado → Muchos deploys → Feedback rápido"] Antes --> Despues classDef before fill:#111827,stroke:#f97316,color:#e5e7eb,stroke-width:1px; classDef after fill:#022c22,stroke:#22c55e,color:#e5e7eb,stroke-width:1px; class Antes before; class Despues after;
🚀 Patrones de migración desde una arquitectura tradicional
No hace falta reescribir el sistema desde cero. Se puede migrar por módulos, de forma incremental.
class MigrationStrategy {
steps = [
{
phase: '1. Extraer Dominio',
tasks: [
'Identificar entidades de negocio',
'Mover lógica desde controllers/services a domain services',
'Definir interfaces de repositories'
]
},
{
phase: '2. Aislar Casos de Uso',
tasks: [
'Crear use cases que orquesten el dominio',
'Extraer DTOs de aplicación',
'Definir ports de entrada'
]
},
{
phase: '3. Implementar Adaptadores',
tasks: [
'Crear repositorios concretos',
'Refactorizar controllers para usar casos de uso',
'Implementar publicación de eventos'
]
}
];
}
✅ Cuándo tiene sentido usar Arquitectura Hexagonal
Buenos candidatos
- Sistemas empresariales con lógica de negocio rica.
- Proyectos que van a vivir años y evolucionar fuerte.
- Equipos que priorizan mantenibilidad y calidad de testing.
- Dominios donde cambiar de tecnología (DB, colas, framework) es probable.
Cuándo puede ser overkill
- Prototipos y MVPs que necesitan validación en semanas.
- CRUDs muy simples sin reglas de negocio relevantes.
- Equipos ultra pequeños con deadlines imposibles.
La arquitectura hexagonal no es un fin en sí mismo, sino un medio para crear software que pueda evolucionar de forma sostenible. Menos acoplamiento, más control y menos incendios en producción a las 3 AM. Tu yo del futuro lo va a agradecer.