← ← Volver a todas las notas

Arquitectura Hexagonal: Más allá de las capas tradicionales

2025-11-22 · Benja

La Arquitectura Hexagonal - Desarrollada por Alistair Cockburn, para resolver el problema fundamental del acoplamiento tecnológico que afecta a la mayoría de proyectos empresariales.

Arquitectura Hexagonal: Más allá de las capas tradicionales
Arquitectura Hexagonal: Más allá de las capas tradicionales

Arquitectura Hexagonal: Más allá de las capas tradicionales (y del caos organizado)

Idea central: separar la lógica de negocio de la tecnología que la rodea (frameworks, bases de datos, protocolos, etc.). El dominio manda, el resto obedece.

🎯 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:

El dominio vive en el centro, aislado, puro y soberano. Todo lo demás gira a su alrededor.

🧭 Diagrama conceptual 1: vista 10.000 ft

Hexágono y sus alrededores

flowchart TB subgraph MundoExterior["Mundo exterior"] UI["UI / CLI / API / Cron"] end subgraph Entrada["Adaptadores de entrada"] InAdapters["Web / Mensajería / CLI"] end subgraph Core["Núcleo de aplicación"] InPorts["Puertos de entrada"] App["Aplicación (Casos de uso)"] OutPorts["Puertos de salida"] end subgraph Salida["Adaptadores de salida"] OutAdapters["DB / Mensajería / Gateways externos"] end UI --> InAdapters InAdapters --> InPorts InPorts --> App App --> OutPorts OutPorts --> OutAdapters

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

flowchart LR Adaptadores["Adaptadores
(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

flowchart LR Cliente["Cliente
(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');
    }
  }
}
Beneficio: los Value Objects reducen ifs repetidos y hacen que la API de dominio sea difícil de usar mal.

🚪 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

flowchart LR Controllers["Controllers / Handlers"] --> PortIn["Puerto de entrada
(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');
  });
});
Punto clave: no necesitás levantar una base de datos para testear reglas de negocio. Los tests lentos quedan relegados a integración/aceptación.

🔄 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

flowchart LR subgraph Write["Write model (Comandos)"] Cmd["Comandos"] --> UC["Use Cases"] UC --> Events["Eventos de dominio"] Events --> ES["Event Store"] end subgraph Read["Read model (Consultas)"] ES -. projecciones .-> Proj["Proyecciones"] Proj --> Queries["Consultas"] end classDef store fill:#0f172a,stroke:#f97316,color:#e5e7eb,stroke-width:1px; class ES store;

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

flowchart LR Antes["Antes:
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'
      ]
    }
  ];
}
Estrategia útil: empezar por el módulo más doloroso (por ejemplo “pedidos”) y usarlo como piloto. Una vez estabilizado el patrón, se replica en el resto del sistema.

✅ 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.