
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 salida
| Pieza | Responsabilidad | Ejemplo |
|---|---|---|
| dominio | reglas y estados de negocio | cuándo un artículo puede publicarse |
| caso de uso | orquesta una intención | publicar un artículo |
| puerto de entrada | expone la intención | PublishArticle |
| puerto de salida | capacidad que el núcleo necesita | ArticleRepository |
| adaptador de entrada | traduce una solicitud | Route Handler, job, CLI |
| adaptador de salida | habla con infraestructura | Supabase, 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 puertos
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:
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:
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.
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 dominio
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 adaptadores
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 cuando | Aplícala parcialmente o evítala cuando |
|---|---|
| el flujo contiene reglas valiosas | es una lectura o CRUD trivial |
| existen varias entradas: HTTP, job, webhook | solo hay una operación estable y directa |
| proveedores externos pueden cambiar | el equipo todavía no entiende el dominio |
| necesitas pruebas rápidas del negocio | aparecen interfaces sin una necesidad concreta |
| un fallo tiene impacto alto | la 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.