Hexagonal Architecture in Modern Web Apps: Domain, Ports, and Adapters

Separate business rules from Next.js, databases, and external services with focused use cases, minimal ports, replaceable adapters, and cheaper tests.

10 min

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

Hexagonal architecture protects business rules so they do not depend on Next.js, Supabase, Stripe, a queue, or a particular interface. The framework still matters, but it stops occupying the conceptual center of the system.

This is not about drawing a hexagon or multiplying folders. It is about controlling dependency direction: the outside knows the core; the core does not know the outside.

The domain speaks its own language. Ports express what it needs, and adapters translate the external world.

01. The minimum map: core, ports, and adapters

Hexagonal architecture map with domain, use cases, ports, and input and output adaptersHexagonal architecture map with domain, use cases, ports, and input and output adapters

PieceResponsibilityExample
domainbusiness rules and stateswhen an article can be published
use caseorchestrates an intentionpublish an article
input portexposes the intentionPublishArticle
output portcapability required by the coreArticleRepository
input adaptertranslates a requestRoute Handler, job, CLI
output adaptertalks to infrastructureSupabase, email, cache

A Route Handler can disappear and be replaced by a job without changing the publishing rule. A database can migrate without forcing the domain to learn another SDK. That is the useful promise.

Deployment architecture is a separate decision: this pattern works inside a modular monolith or a microservice. Review that relationship in Modular Monolith, Microservices, and Events.

02. The symptom: the handler knows too much

A flow often starts directly inside the framework:

Comparison between a coupled handler and a use case isolated through portsComparison between a coupled handler and a use case isolated through ports

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 });
}

This code is not wrong because it is short. The problem appears when it must also validate permissions, translations, required fields, auditing, and notifications. The route ends up mixing HTTP protocol, rules, persistence, and side effects.

The signal to separate is not line count. It is that the same intention needs to run from several entry points or its rules can no longer be explained without mentioning infrastructure.

03. The core: pure rules and use cases

First express the rule with application-owned types:

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.");
  }
}

This function receives no Request, Supabase row, or Next.js object. It can be tested with in-memory data and expresses a product decision.

The use case coordinates that rule with the external capabilities it needs. It should not know how they are implemented.

04. Ports grow from a real need

Define small contracts from the use case's point of view:

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);
}

A port is not a copy of every SDK function. It contains only the operations this intention needs. If an interface grows until it represents the whole database, it has stopped protecting the use case.

05. Adapters concentrate the details

An adapter implements a port with a concrete technology. Table names, provider errors, row mapping, and framework APIs belong here.

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;
    },
  };
}

An adapter may be specific and inelegant; its job is to prevent those details from spreading. It is also the right place to translate technical failures into errors the application can handle.

06. Composition in a modern Route Handler

The edge creates adapters, translates input, and invokes the use case. In current dynamic routes, params is a promise:

Composition of a dynamic Route Handler with asynchronous params, repository, cache, and domain ruleComposition of a dynamic Route Handler with asynchronous params, repository, cache, and domain rule

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 });
}

The route knows Next.js and Supabase because it is an input adapter and composition point. publishArticle remains an application function independent of transport.

One possible structure is domain/, application/, ports/, infrastructure/, and app/, but dependency direction matters more than folder names.

07. Cheap tests at the correct boundary

Ports allow policy tests without a server or database:

Testing strategy separating fast core tests from focused adapter integration testsTesting strategy separating fast core tests from focused adapter integration tests

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"]);

These tests verify rules and orchestration. Adapters need separate integration tests proving that the contract is actually satisfied against Supabase, Next.js, or another provider.

Do not mock an SDK's internal call chain in the use-case test. That couples the test to the detail the architecture is intended to isolate.

08. Apply it without turning it into ceremony

Use it whenApply partially or avoid when
the flow contains valuable rulesit is a trivial read or CRUD operation
several inputs exist: HTTP, job, webhookthere is one stable, direct operation
external providers may changethe team does not understand the domain yet
fast business tests are valuableinterfaces appear without a concrete need
a failure has high impactseparation adds more navigation than clarity

Common mistakes include creating interfaces for everything, leaking SDK types into the core, splitting before understanding the domain, and ignoring transactions. The correct separation should make the flow easier to explain, not merely longer.

Start with one important intention. Extract its rule, define the minimum ports, implement adapters, and compare the result: if testing, changing, and following the flow becomes easier, the boundary is adding value.

Hexagonal architecture does not make a system clean by itself. It offers a verifiable direction: business decisions remain in the center and replaceable details stay at the edges.


SESSION_ELAPSED00:00:00
LOCALE: ENENV: PROD