3001 words
15 minutes
Hexagonal Architecture, Revisited: Four Layers and Two Arrows
阅读中文版

I’ve been studying hexagonal architecture (ports and adapters) from both ends: reading a service at work that was already built that way, and building a minimal version from scratch in an empty repo.

What follows is ordered by what matters, not by the order I figured it out. The first two sections are the foundation; everything else follows from them.


1. Four layers, and the question each one answers#

Here is the whole mental model up front. Most introductions skip the application layer, and that is precisely the one people get wrong in practice.

api/ inbound adapter how the outside gets in
application/ use cases in what order, and what if it fails
domain/ rules + ports what is true, what counts
adapters/ outbound adapter how it actually gets stored / called
bootstrap.py composition root which implementation

Each layer answers a different question, and the five questions don’t overlap. Deciding where a piece of code belongs is just deciding which question it answers.

The application layer sits inside the hexagon, wrapped around the domain. An inbound adapter calls into the application. The application orchestrates the domain, and reaches outward only through a port that sits on the domain's edge. A solid arrow from that port down to the outbound adapter is the runtime call; a dashed arrow back up from the adapter to the port is the dependency, pointing inward. Off to the side and outside everything, the composition root constructs the outbound adapter — it is the only box that names a concrete implementation.

Concretely — this is the orchestration function from that real service:

async def run_pipeline(raw_bytes, opts, ctx, saver: StoragePort):
document = load(raw_bytes)
pages = list(range(1, document.page_count + 1))
blocks = prepare(document, pages)
result = await analyze(blocks, opts, ctx)
if not result.usable:
return finish(None)
output = assemble(result, ctx)
validate(output, ctx)
saver.save(output)
return finish(output)

It drives every other module, which makes it look like a hub. But the question it answers is “in what order”, so it is application — not domain, and not the composition root.

It is not the composition root, because its signature says saver: StoragePort — an interface. It constructs nothing; not one concrete class name appears in it. It is a consumer of the composition root.

The two kinds of “knowing a lot” are different in kind: the composition root knows which implementations exist, the application knows the steps and their order. One is the electrician, the other is the script. The script doesn’t care whether the socket leads to production or to a test fake.

It is not domain either, but that needs a sharper ruler.


2. The foundation: calling and depending are two separate arrows#

The layering above isn’t taste. It falls out of dependency direction.

Between any two modules there are really two different arrows:

  • who calls whom at runtime
  • who imports whom in the source

Normally they point the same way, so we never distinguish them.

On the inbound side they genuinely do. The HTTP adapter calls domain and imports domain. No tension.

On the outbound side, hexagonal bends them apart. Domain calls FileResultSaver at runtime, yet never imports it; instead FileResultSaver imports the port that lives in domain.

That reads like a paradox at first — how can you call something you don’t know?

The port is the answer: domain knows a shape, not an implementation.

Here is the default state, what you get by designing nothing:

domain.py
from adapters import FileStorage # I call it, so I import it

That isn’t the wrong way to write it. That’s the natural state. Do nothing and it looks like this, both arrows aligned.

The one thing hexagonal does is put a Protocol in domain so the adapter imports it instead:

calls: domain ────────────→ adapter (unchanged)
depends: domain ←──────────── adapter (bent back)

The port is the fulcrum. With it, “calling something” and “depending on something” come apart for the first time.

The inversion in Dependency Inversion is literal: what gets reversed is this dependency arrow relative to the call arrow. Not a metaphor.

A hexagon labelled DOMAIN sits in the centre. On the left, two inbound adapters, an HTTP API and a CLI, both call into the domain's left edge with no formal port. On the right edge, two port slots labelled StoragePort and NotifierPort each connect to an outbound adapter by a solid runtime-call arrow pointing outward and a dashed implements arrow pointing back inward.

This also explains why ports.py sits under domain/ rather than somewhere more peripheral: the port is domain’s request. “I need something that can store things, shaped like this” — domain says that; the adapter merely satisfies it. The contract belongs to whoever asks.

Why only one side has ports#

If ports are this useful, why doesn’t the inbound side have them?

Because nothing on that side needs inverting. A callee never needs to import its caller — you write a function without knowing in advance who will call it. The signature is the contract.

You only declare contracts for what you call outward.

Where application sits#

Application is inside the hexagon, wrapped around domain. And — this is easy to get wrong — it doesn’t connect to adapters, it connects to ports.

It looks like it touches adapters because the object in its hand at runtime really is an adapter instance. But all it knows is that it’s a StoragePort. Which one it actually is, only the composition root ever knew, and only for an instant.

Neither of the two arrows above changes when you add application — this layer doesn’t take part in the inversion, it just stands on top of where the inversion happens.

Which is also why run_pipeline is so easy to test: hand it a fake StoragePort. No patching required.


3. The line between application and domain#

Section 1 owed you a ruler. This is the sharpest one I found:

If the product says “run it through two models and merge the results”, which files change?

Only run_pipeline. Not analyze, not assemble, not validate — they don’t know how many times they were called.

Exactly one file has an opinion about “how many times”. That file is the use case.

Conversely, “how the checksum is computed” is that algorithm forever, independent of ordering and retries. That’s domain.

domain serviceapplication service
Answerswhat is true / what countsin what order, and what if it fails
Grainone step, one ruleone complete use case
Namingnouns (price_book)verbs (run_import)

That last row turns out to be a useful signal. I went through another service at work: application/use_cases/ was all verbs — run_import, push_to_warehouse, export_report. domain/services/ was all nouns — price_book, usage_window, settlement_batch.

A verb is something a person asked the system to do. A noun is a business concept that knows its own rules.

Folders attract things#

This boundary is not naming fastidiousness.

In that service, run_pipeline originally sat under domain/services/. Its dependency direction was clean — it imported only domain — so looking at that one file, moving it changes nothing.

But a feature was coming: on a hard failure, re-run with a stronger model. That logic is application’s. “What counts as failure” is a business rule; “what to do about it” is use-case policy.

If run_pipeline stays under domain/services/, the natural place for that new code is right next to it — which means inside domain.

A misplaced file is cosmetic. A misplaced folder keeps attracting the wrong things.

So the trigger isn’t “wait until it imports an adapter”. It’s when the second application-layer thing gets written. Move it while it’s still one file: one git mv and one import line.

domain holds four different kinds of thing#

domain/ is not a synonym for services. There are at least four kinds under it, and only one is a service:

domain/
├── models.py entities and value objects
├── schema.py output contract
├── ports.py interfaces
└── services/ homeless rules

The test: a rule that clearly belongs to one entity should live on that entity, not become a service.

checksum_is_valid(code: str) -> bool is genuinely homeless — it’s a rule about a string; Order owning it is a stretch and Invoice is worse, so it became a service.

The counter-case: writing “is this document empty” as document_is_empty(doc) instead of a property on SourceDocument strips behaviour off the data. That’s an anemic model.

So application strings together more than services — it also strings together rules the models themselves carry. It sequences steps; where the rule inside a step lives is a separate question.


4. The composition root: the only place allowed to say a concrete name#

After the inversion, domain and application see nothing but interfaces. But the program has to actually run — someone has to finally say the name FileResultSaver.

I hit this in the toy repo after adding a second entry point (a CLI): cli.py and app.py each wrote their own FileResultSaver(...). Two entry points, two decisions about which implementation. A third entry point makes three.

So the decision moved into one file:

bootstrap.py
def make_saver() -> ResultSaverPort:
return FileResultSaver("results.json")

Then a check: change make_saver() to return InMemoryResultSaver(). Both entry points keep working, no file is produced, and git status shows exactly one changed file.

Before and after comparison. Before: the CLI and the HTTP API each construct FileStorage themselves, two duplicated wiring edges. After: both call the composition root, which is the single edge that reaches FileStorage.

It lives at the outermost layer, which is counterintuitive — it knows everything, so it feels like it should be at the centre.

But layers are ordered by who depends on whom, not by importance. The composition root depends on every concrete implementation, so a change to any of them can ripple into it. It has to sit at the edge, or its changes propagate inward.

One-line test: if you published this code as a library, which part would have to be deleted? That’s the composition root. Libraries never ship one; the application using them supplies it.

“Knows everything” is not “at the centre”. The more it knows, the further out it goes.

Three nested rounded rectangles. The innermost, labelled domain and ports, depends on nothing. The middle ring is adapters, which depend on the domain and may depend on each other. The outermost ring is the composition root, which depends on everything and is depended on by nothing.

It isn’t a layer, it’s a moment#

Calling it a layer is inaccurate — it’s often a single file.

Layering is a static question: who may import whom, decidable from the source, fixed at compile time. The composition root is a dynamic question: at which moment the concrete type is spoken aloud.

It isn’t a layer. It’s a moment.

And the reason it must exist: dependency inversion cannot invert forever. Someone has to say the name eventually or nothing runs. The composition root is the one place allowed to say it, and the price is that it may say it only once, at the very start, before stepping off stage — after which only interfaces move through the system.

That’s the other face of the library test. A library can’t ship its own composition root precisely because when to choose an implementation belongs to whoever uses it, not to the library.


5. What this pattern does not guarantee#

The first four sections are what it does. This one is what it doesn’t, because almost nobody writes this part, and it determines whether you need to add something else.

A Protocol guarantees shape, never behaviour#

Consider these two classes:

class NullResultSaver(ResultSaverPort): # does nothing, on purpose
def save(self, n, is_odd) -> None: pass
class BrokenFileSaver(ResultSaverPort): # forgot to write the file — a bug
def save(self, n, is_odd) -> None: pass

The code can be identical. Same shape, opposite intent. The type checker passes both; neither crashes at runtime.

A Protocol makes implementations substitutable, not correct. Only tests separate them — the null adapter’s test asserts the directory is still empty; the file adapter’s asserts those three lines appeared. Intent lives in assertions.

One more thing: a Protocol is zero-cost and zero-effect at runtime. Return something that doesn’t satisfy the port at all from the composition root, and Python doesn’t care at the moment of injection — it runs all the way to the domain call before raising AttributeError, two layers away from the cause. Protocols exist entirely for the static type checker and for whoever reads the code. If your CI doesn’t run mypy or pyright, they degrade into documentation.

Nothing enforces the dependency direction#

I rewrote the toy repo’s domain into the “dead port” shape — three edits:

from hex_lab.adapters import FileResultSaver # ① domain imports the concrete class
def check_and_save(n: int, saver: FileResultSaver | None = None) -> bool: # ② annotated concretely
saver = saver or FileResultSaver("results.json") # ③ domain constructs it
result = _is_odd(n)
saver.save(n, result)
return result

port.py, adapters.py, and bootstrap.py were untouched. Then three commands.

The program ran. Every test passed.

uv run --with mypy mypy src/
Success: no issues found in 7 source files

mypy was completely green.

Architectural violations are invisible to the type system. mypy checks “can A be used as B”. It never checks “should A know about B”. No type checker warns you when you point the dependency arrow backwards.

Tests didn’t catch it either — they all injected the saver explicitly, so none ever reached that default branch.

This boundary can only be held by people. Automating it means adding something like import-linter, which exists specifically to declare “this layer may not import that one”. That’s an extra tool, not something the type checker gives you free.

Two panels. On the left, a live port: the domain depends on StoragePort, FileStorage implements StoragePort, and the composition root constructs FileStorage. On the right, a dead port: the domain imports and constructs FileStorage directly, bypassing StoragePort, which sits unreferenced, and the composition root is never called.

And the feel of the broken version is very concrete: ResultSaverPort drops to one reference — its own definition — and becomes dead code; bootstrap.py is called by nobody and is hollowed out; changing the default to “don’t persist” now means opening the pure business-logic file; and "results.json", a piece of infrastructure config, is hardcoded into the business layer.


6. When not to use it#

The inversion isn’t free. Three costs.

One more file and one more type. The obvious one.

The inbound adapter has to carry an object it never uses. Look at what cli.py does:

saver = make_saver()
result = check_and_save(n, saver)

It never calls saver.save() once. In other contexts this is a named smell — middle man, tramp data — but here it’s deliberate: the moment you let domain fetch it itself, you’re back to the dead-port version from the last section. Carrying a dependency you don’t use is both the cost of decoupling and the mechanism of it.

Reading domain code no longer tells you what actually runs. One more level of indirection, and that’s a real cognitive cost.

There are only two benefits: you can swap the implementation, and you can isolate I/O in tests.

So not every dependency deserves a port. For one that will never be swapped and never needs isolating in a test, a port is pure cost.

The test compresses to one line: a port’s value is strictly tied to whether you will actually have a second implementation.

A real comparison#

Back to the service I read. It defines two ports. Grepping the repo with the ruler above:

PortA → 1 occurrence in the whole repo: its own definition. Zero references.
PortB → 5 occurrences: type annotations across three files, plus injection.

PortA is dead code. Two domain services import the concrete adapter class directly, annotate parameters with the concrete type, and construct a default inside the function body — matching the broken version from the last section symptom for symptom.

PortB has three implementations: one production, two test doubles.

The difference isn’t design skill. It’s whether testing forced a second implementation into existence.

Behind PortA is a PDF parsing library. Nobody needs to swap it and tests can use real files, so a second implementation was never built, and the inversion decayed back to the default state.

Why LLM calls are the best case for a port#

If you build AI-adjacent systems, this pattern pays off more for you than for most people, for three reasons specific to the setting:

First, the dependency really does change, and often. Provider, access path (direct API vs a cloud vendor’s hosting vs an internal gateway), model ID — something moves every few months. Compare a PDF library, which you won’t replace in three years.

Second, testing forces a second implementation on you. Real LLM calls are slow (seconds), cost money, and are non-deterministic. No test that has to run fast can hit the real API. Note the causality — it isn’t “we designed well, so we have two implementations”, it’s “tests can’t use the real call, so the second implementation was forced into existence”.

Third, you need pinned output to test what comes after. Downstream of the LLM sits a long stretch of deterministic logic: parsing, schema validation, assembly, repairing malformed JSON. All of it is testable, but only if you can inject a fixed response. The port is that injection point.

One trap worth calling out: it’s easy to shape the port like one specific provider. If concepts only one vendor has show up in your port’s signature — a particular content-block structure, a vendor-specific parameter name — it isn’t a port, it’s a wrapper. Domain still has to change when you swap providers.

The self-check is simple: hand this port’s signature to a different provider to implement. Does the signature need to change? If yes, the abstraction leaks.

And one boundary to settle early: which side of the port do retry, backoff, and rate limiting live on? Inside domain, you rewrite them for every provider. In the adapter or the gateway, domain never learns retries exist. Decide it sooner rather than later.


Closing#

If only one line survives, it’s from section 2:

Between any two modules there are two separate arrows — who calls whom, and who imports whom. They normally align, so we never distinguish them; everything hexagonal does is bend the second arrow back on the outbound side.

The four layers follow from that: the port is the fulcrum, the composition root is the one moment allowed to say a concrete class name, and application is the script standing on top of the inversion.

And the least intuitive part is section 5: the pattern’s central mechanism — the direction of dependency — is invisible at runtime, uncaught by tests, and unreported by the type checker. No tool holds that line. Only people do.