SOLID y la arquitectura hexagonal

Por: Artiko
solidarquitectura-hexagonalpuertos-adaptadoresdipisptypescript

SOLID y la arquitectura hexagonal

En el capítulo anterior refactorizamos una clase aplicando los cinco principios y, al final, notamos algo: el diagrama resultante tenía todas las dependencias del núcleo apuntando hacia abstracciones. Ese patrón no es una coincidencia. Es exactamente la forma de una de las arquitecturas más influyentes de las últimas dos décadas: la arquitectura hexagonal, también llamada puertos y adaptadores, propuesta por Alistair Cockburn en 2005.

La idea de este capítulo es la más importante del curso a nivel de diseño: SOLID no vive solo dentro de una clase. Cuando lo aplicás a escala de arquitectura, obtenés la arquitectura hexagonal casi por gravedad. DIP e ISP son el motor; el hexágono es el resultado.

La arquitectura hexagonal es DIP a escala de arquitectura

Recordá el enunciado del Principio de Inversión de Dependencias:

Los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones. Y las abstracciones no deben depender de los detalles; los detalles deben depender de las abstracciones.

En una clase, “alto nivel” es la lógica de negocio y “bajo nivel” es MySQL o SMTP. En una aplicación entera, el alto nivel es todo tu dominio (las reglas del negocio, los casos de uso) y el bajo nivel es toda tu infraestructura (bases de datos, APIs externas, frameworks web, colas de mensajes, el sistema de archivos).

La arquitectura hexagonal toma DIP y lo eleva a regla estructural del proyecto entero:

El dominio no depende de la infraestructura. Ambos dependen de puertos (abstracciones que pertenecen al dominio).

El detalle crucial —el que hace que todo funcione— es quién define las abstracciones. En el hexágono, los puertos pertenecen al dominio. Es el dominio el que dicta “necesito poder guardar un pedido en algún lado” a través de una interfaz que él mismo declara. La infraestructura llega después y dice “yo sé cómo hacer eso con Postgres” implementando esa interfaz. La dependencia queda invertida: la infraestructura depende del dominio, nunca al revés.

Puertos y adaptadores

El vocabulario del hexágono tiene dos palabras. Vale la pena precisarlas porque toda la arquitectura se apoya en ellas.

Un puerto es una interfaz que el dominio define para hablar con el mundo exterior. Es una abstracción, un contrato, no una implementación. Hay dos familias de puertos según la dirección en que fluye el control:

Un adaptador es una implementación concreta de un puerto: el detalle que enchufa el puerto a una tecnología real.

La metáfora es la de los puertos USB de una computadora: el puerto (la ranura) define un contrato estable; podés enchufar cualquier adaptador (un mouse, un teclado, un disco) que respete ese contrato, y la computadora no necesita saber cuál conectaste.

Los puertos son ISP en acción

Acá entra el segundo principio protagonista. Un error frecuente al diseñar la capa de datos es crear un DAO gigante: una interfaz RepositorioPedidos con guardar, buscarPorId, buscarPorCliente, listarTodos, contar, eliminar, actualizarEstado, buscarPendientes… veinte métodos que ningún caso de uso usa completos.

El hexágono, bien hecho, define los puertos por caso de uso, siguiendo el Principio de Segregación de Interfaces: cada puerto expone solo lo que ese caso de uso necesita.

// ❌ Un DAO gigante: viola ISP. Todo caso de uso arrastra métodos que no usa.
interface RepositorioPedidosDAO {
  guardar(pedido: Pedido): Promise<void>;
  buscarPorId(id: string): Promise<Pedido | null>;
  buscarPorCliente(email: string): Promise<Pedido[]>;
  listarTodos(): Promise<Pedido[]>;
  eliminar(id: string): Promise<void>;
  contarPendientes(): Promise<number>;
}

// ✅ Puertos finos, específicos del caso de uso: ISP aplicado.
interface GuardaPedidos {
  guardar(pedido: Pedido): Promise<void>;
}

interface BuscaPedidos {
  porId(id: string): Promise<Pedido | null>;
}

El caso de uso “crear pedido” depende de GuardaPedidos y nada más. El caso de uso “consultar pedido” depende de BuscaPedidos. Un mismo adaptador Postgres puede implementar ambas interfaces, pero cada cliente ve solo el contrato que consume. Interfaces finas, específicas del caso de uso: eso es ISP, y es lo que mantiene sanos a los puertos.

Ejemplo concreto: el caso de uso CrearPedido

Bajemos todo esto a código. Modelemos un caso de uso CrearPedido que necesita persistir el pedido. El dominio define el puerto; la infraestructura provee el adaptador.

Primero, el dominio: la entidad y el puerto que ella misma declara. Fijate que este archivo no importa nada de infraestructura —ni Postgres, ni Express, ni un ORM. Es TypeScript puro.

// dominio/Pedido.ts — la entidad, reglas de negocio puras
export class Pedido {
  private constructor(
    public readonly id: string,
    public readonly clienteEmail: string,
    public readonly total: number,
  ) {}

  static crear(id: string, clienteEmail: string, total: number): Pedido {
    if (!clienteEmail.includes("@")) throw new Error("Email inválido");
    if (total < 0) throw new Error("El total no puede ser negativo");
    return new Pedido(id, clienteEmail, total);
  }
}

// dominio/puertos/RepositorioPedidos.ts — PUERTO secundario (driven)
// La interfaz pertenece al dominio; describe lo que el dominio NECESITA.
export interface RepositorioPedidos {
  guardar(pedido: Pedido): Promise<void>;
}

Ahora la capa de aplicación: el caso de uso. Depende del puerto, no del adaptador. Recibe la abstracción por constructor (DIP, inyección de dependencias).

// aplicacion/CrearPedido.ts — caso de uso (alto nivel)
import { Pedido } from "../dominio/Pedido";
import type { RepositorioPedidos } from "../dominio/puertos/RepositorioPedidos";

interface ComandoCrearPedido {
  id: string;
  clienteEmail: string;
  total: number;
}

export class CrearPedido {
  // Depende de la ABSTRACCIÓN, nunca del adaptador concreto.
  constructor(private readonly repositorio: RepositorioPedidos) {}

  async ejecutar(comando: ComandoCrearPedido): Promise<void> {
    const pedido = Pedido.crear(
      comando.id,
      comando.clienteEmail,
      comando.total,
    );
    await this.repositorio.guardar(pedido);
  }
}

Finalmente la infraestructura: el adaptador concreto que implementa el puerto. Este archivo importa del dominio (la interfaz), pero el dominio nunca importa de él. La dependencia apunta hacia adentro.

// infraestructura/RepositorioPedidosPostgres.ts — ADAPTADOR secundario
import { Pedido } from "../dominio/Pedido";
import type { RepositorioPedidos } from "../dominio/puertos/RepositorioPedidos";

export class RepositorioPedidosPostgres implements RepositorioPedidos {
  constructor(private readonly pool: PgPool) {}

  async guardar(pedido: Pedido): Promise<void> {
    await this.pool.query(
      "INSERT INTO pedidos (id, email, total) VALUES ($1, $2, $3)",
      [pedido.id, pedido.clienteEmail, pedido.total],
    );
  }
}

Y el cableado, en el borde de la aplicación (la única capa que conoce a todos):

// main.ts — composición: acá se enchufan los adaptadores a los puertos
const pool = new PgPool({ connectionString: process.env.DATABASE_URL });
const repositorio = new RepositorioPedidosPostgres(pool);
const crearPedido = new CrearPedido(repositorio);

// Un adaptador primario (controller REST) maneja el caso de uso:
app.post("/pedidos", async (req, res) => {
  await crearPedido.ejecutar(req.body);
  res.status(201).send();
});

El poder de este diseño se ve al probarlo. En un test no necesitás Postgres: inyectás un adaptador en memoria que implementa el mismo puerto.

// test — un adaptador falso que respeta el puerto (LSP: es sustituible)
class RepositorioPedidosEnMemoria implements RepositorioPedidos {
  public readonly guardados: Pedido[] = [];
  async guardar(pedido: Pedido): Promise<void> {
    this.guardados.push(pedido);
  }
}

const repo = new RepositorioPedidosEnMemoria();
await new CrearPedido(repo).ejecutar({
  id: "p-1",
  clienteEmail: "[email protected]",
  total: 100,
});
// assert: repo.guardados.length === 1

Estructura de carpetas

La organización física del proyecto refleja las tres capas. La regla es simple: dominio/ no importa de infraestructura/; nunca al revés.

El hexágono, visualmente

El nombre “hexagonal” viene del dibujo: el dominio en el centro, rodeado por un borde de puertos, y los adaptadores enchufados por fuera. La única regla que importa es la regla de dependencia: todas las flechas apuntan hacia adentro.

flowchart LR
    subgraph EXTERIOR["Adaptadores (infraestructura)"]
        REST["Adaptador primario<br/>Controller REST"]
        CLI["Adaptador primario<br/>CLI / Cola"]
        PG["Adaptador secundario<br/>RepositorioPedidosPostgres"]
        MAIL["Adaptador secundario<br/>NotificadorEmailSendgrid"]
    end

    subgraph BORDE["Puertos (definidos por el dominio)"]
        PP["Puerto primario<br/>CrearPedido / ConsultarPedido"]
        PS["Puerto secundario<br/>RepositorioPedidos / Notificador"]
    end

    subgraph NUCLEO["Dominio (alto nivel)"]
        DOM["Entidades + reglas<br/>Pedido"]
    end

    REST -->|"maneja"| PP
    CLI -->|"maneja"| PP
    PP --> DOM
    DOM -->|"usa"| PS
    PS -.->|"implementado por"| PG
    PS -.->|"implementado por"| MAIL

    style NUCLEO fill:#166534,color:#fff
    style BORDE fill:#1e40af,color:#fff
    style EXTERIOR fill:#9a3412,color:#fff

Leé el diagrama de afuera hacia adentro: los adaptadores primarios (izquierda) empujan al dominio a través de los puertos primarios; el dominio pide a la infraestructura a través de los puertos secundarios, que los adaptadores secundarios (derecha) implementan. En ningún momento el verde (dominio) apunta hacia el naranja (infraestructura). Esa es la inversión de dependencias hecha arquitectura.

Dónde aparece cada principio SOLID

La arquitectura hexagonal no usa SOLID: es SOLID llevado a escala. Cada principio tiene su lugar exacto en el hexágono:

Si tuvieras que quedarte con una sola frase: la arquitectura hexagonal es lo que pasa cuando aplicás DIP no a una clase, sino a tu proyecto entero.

Para profundizar

Este capítulo conecta SOLID con la arquitectura hexagonal desde la óptica de los principios. Si querés meterte de lleno en puertos y adaptadores —modelado del dominio, casos de uso, testing de aceptación, organización de un proyecto real de punta a punta— en este mismo sitio hay un curso dedicado a la arquitectura hexagonal: Arquitectura hexagonal →.

Resumen

En el próximo capítulo bajamos a la tierra con el reverso necesario de todo esto: cuándo NO aplicar SOLID, la sobre-ingeniería y los anti-patrones que aparecen al tomarse los principios como dogma.

Siguiente: Anti-patrones y cuándo NO aplicar SOLID →