
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 adapters
| Piece | Responsibility | Example |
|---|---|---|
| domain | business rules and states | when an article can be published |
| use case | orchestrates an intention | publish an article |
| input port | exposes the intention | PublishArticle |
| output port | capability required by the core | ArticleRepository |
| input adapter | translates a request | Route Handler, job, CLI |
| output adapter | talks to infrastructure | Supabase, 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 ports
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:
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:
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.
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 rule
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 tests
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 when | Apply partially or avoid when |
|---|---|
| the flow contains valuable rules | it is a trivial read or CRUD operation |
| several inputs exist: HTTP, job, webhook | there is one stable, direct operation |
| external providers may change | the team does not understand the domain yet |
| fast business tests are valuable | interfaces appear without a concrete need |
| a failure has high impact | separation 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.