Arquitectura hexagonal en aplicaciones web: dominio, puertos y adaptadores

Separa las reglas de negocio de Next.js, bases de datos y servicios externos mediante casos de uso, puertos mínimos, adaptadores reemplazables y pruebas más baratas.

10 min

Póster de arquitectura hexagonal con un núcleo protegido conectado mediante seis puertos a adaptadores reemplazables

La arquitectura hexagonal protege las reglas de negocio para que no dependan de Next.js, Supabase, Stripe, una cola o una interfaz concreta. El framework sigue siendo importante, pero deja de ocupar el centro conceptual del sistema.

No se trata de dibujar un hexágono ni de multiplicar carpetas. Se trata de controlar la dirección de las dependencias: el exterior conoce al núcleo; el núcleo no conoce al exterior.

El dominio habla en su propio idioma. Los puertos expresan lo que necesita y los adaptadores traducen el mundo externo.

01. El mapa mínimo: núcleo, puertos y adaptadores

Mapa de arquitectura hexagonal con dominio, casos de uso, puertos y adaptadores de entrada y salidaMapa de arquitectura hexagonal con dominio, casos de uso, puertos y adaptadores de entrada y salida

PiezaResponsabilidadEjemplo
dominioreglas y estados de negociocuándo un artículo puede publicarse
caso de usoorquesta una intenciónpublicar un artículo
puerto de entradaexpone la intenciónPublishArticle
puerto de salidacapacidad que el núcleo necesitaArticleRepository
adaptador de entradatraduce una solicitudRoute Handler, job, CLI
adaptador de salidahabla con infraestructuraSupabase, email, cache

Un Route Handler puede desaparecer y ser sustituido por un job sin cambiar la regla de publicación. Una base de datos puede migrar sin obligar al dominio a conocer otro SDK. Esa es la promesa útil.

La arquitectura de despliegue es otra decisión: este patrón funciona dentro de un monolito modular o dentro de un microservicio. Puedes revisar esa relación en Monolito modular, microservicios y eventos.

02. El síntoma: el handler sabe demasiado

Un flujo suele empezar directamente en el framework:

Comparación entre un handler acoplado y un caso de uso aislado mediante puertosComparación entre un handler acoplado y un caso de uso aislado mediante puertos

ts
export async function POST(request: Request) {
  const draft = await request.json();
  await supabase.from("articles").update({ is_published: true }).eq("slug", draft.slug);
  revalidatePath(`/articles/${draft.slug}`);
  return Response.json({ ok: true });
}

Este código no es incorrecto por ser corto. El problema aparece cuando también debe validar permisos, traducciones, campos obligatorios, auditoría y notificaciones. La ruta termina mezclando protocolo HTTP, reglas, persistencia y efectos secundarios.

La señal para separar no es el número de líneas. Es que la misma intención necesite ejecutarse desde varios lugares o que sus reglas ya no puedan explicarse sin mencionar infraestructura.

03. El núcleo: reglas puras y casos de uso

Primero expresa la regla con tipos propios:

ts
type ArticleDraft = {
  slug: string;
  title: string;
  excerpt: string;
  content: string;
  coverImage: string;
};

function assertPublishable(article: ArticleDraft) {
  const required = [article.title, article.excerpt, article.content, article.coverImage];
  if (required.some((value) => value.trim() === "")) {
    throw new Error("Article is missing publication fields.");
  }
}

Esta función no recibe Request, filas de Supabase ni objetos de Next.js. Puede probarse con datos en memoria y expresa una decisión del producto.

El caso de uso coordina esa regla con las capacidades externas necesarias. No debería saber cómo se implementan.

04. Los puertos nacen de una necesidad real

Define contratos pequeños desde el punto de vista del caso de uso:

ts
type ArticleRepository = {
  findDraft(slug: string): Promise<ArticleDraft | null>;
  markPublished(slug: string): Promise<void>;
};

type ArticleCache = {
  invalidate(slug: string): Promise<void>;
};

async function publishArticle(
  slug: string,
  deps: { articles: ArticleRepository; cache: ArticleCache },
) {
  const article = await deps.articles.findDraft(slug);
  if (!article) throw new Error("Article not found.");

  assertPublishable(article);
  await deps.articles.markPublished(slug);
  await deps.cache.invalidate(slug);
}

El puerto no es una copia de todas las funciones del SDK. Solo contiene las operaciones que esta intención necesita. Si una interfaz crece hasta representar la base de datos completa, dejó de proteger al caso de uso.

05. Los adaptadores concentran los detalles

El adaptador implementa el puerto con una tecnología concreta. Aquí sí pertenecen nombres de tablas, errores del proveedor, mapeo de filas y APIs del framework.

ts
function createArticleRepository(client: SupabaseClient): ArticleRepository {
  return {
    async findDraft(slug) {
      const { data, error } = await client
        .from("articles")
        .select("slug,title,excerpt,content,cover_image")
        .eq("slug", slug)
        .maybeSingle();

      if (error) throw error;
      return data ? mapRowToDraft(data) : null;
    },
    async markPublished(slug) {
      const { error } = await client
        .from("articles")
        .update({ is_published: true })
        .eq("slug", slug);

      if (error) throw error;
    },
  };
}

Un adaptador puede ser específico y poco elegante; su trabajo es impedir que esos detalles se propaguen. También es el lugar correcto para traducir errores técnicos a errores que la aplicación pueda manejar.

06. Composición en un Route Handler moderno

El borde crea adaptadores, traduce la entrada e invoca el caso de uso. En rutas dinámicas actuales, params es una promesa:

Composición de un Route Handler dinámico con params asíncronos, repositorio, cache y regla de dominioComposición de un Route Handler dinámico con params asíncronos, repositorio, cache y regla de dominio

ts
export async function POST(
  _request: Request,
  ctx: RouteContext<"/api/admin/articles/[slug]/publish">,
) {
  const { slug } = await ctx.params;

  await publishArticle(slug, {
    articles: createArticleRepository(await createSupabaseClient()),
    cache: createNextArticleCache(),
  });

  return Response.json({ ok: true });
}

La ruta conoce Next.js y Supabase porque es un adaptador de entrada y un punto de composición. publishArticle continúa siendo una función de aplicación independiente del transporte.

Una estructura posible es domain/, application/, ports/, infrastructure/ y app/; pero las dependencias importan más que los nombres de las carpetas.

07. Pruebas baratas en el límite correcto

Los puertos permiten probar la política sin servidor ni base de datos:

Estrategia de pruebas separando tests rápidos del núcleo y tests de integración de adaptadoresEstrategia de pruebas separando tests rápidos del núcleo y tests de integración de adaptadores

ts
const articles: ArticleRepository = {
  findDraft: async () => validDraft,
  markPublished: async (slug) => published.push(slug),
};

const cache: ArticleCache = {
  invalidate: async (slug) => invalidated.push(slug),
};

await publishArticle("hexagonal-architecture", { articles, cache });
expect(published).toEqual(["hexagonal-architecture"]);
expect(invalidated).toEqual(["hexagonal-architecture"]);

Estas pruebas verifican reglas y orquestación. Los adaptadores necesitan pruebas de integración separadas para demostrar que el contrato realmente se cumple contra Supabase, Next.js u otro proveedor.

No mockees cadenas internas del SDK dentro de la prueba del caso de uso. Eso acopla la prueba al detalle que la arquitectura intenta aislar.

08. Aplicarla sin convertirla en ceremonia

Úsala cuandoAplícala parcialmente o evítala cuando
el flujo contiene reglas valiosases una lectura o CRUD trivial
existen varias entradas: HTTP, job, webhooksolo hay una operación estable y directa
proveedores externos pueden cambiarel equipo todavía no entiende el dominio
necesitas pruebas rápidas del negocioaparecen interfaces sin una necesidad concreta
un fallo tiene impacto altola separación agrega más navegación que claridad

Errores frecuentes son crear interfaces para todo, filtrar tipos de SDK al núcleo, dividir antes de entender el dominio e ignorar transacciones. La separación correcta debe hacer el flujo más fácil de explicar, no solo más largo.

Empieza con una intención importante. Extrae su regla, define los puertos mínimos, implementa los adaptadores y compara el resultado: si probar, cambiar y seguir el flujo es más sencillo, el límite está aportando valor.

La arquitectura hexagonal no hace limpio un sistema por sí sola. Ofrece una dirección verificable: las decisiones de negocio permanecen en el centro y los detalles reemplazables quedan en los bordes.


SESSION_ELAPSED00:00:00
LOCALE: ESENV: PROD