This website uses cookies

Read our Privacy policy and Terms of use for more information.

A team I talked to last year was proud of their "hexagonal" service. Clean folders: domain, ports, adapters. They had interfaces. They had a dependency injection container. They had read the blog posts. And their core business logic could not be unit tested without spinning up Postgres, because the port for their user repository returned a SQLAlchemy model object. The domain imported the ORM. The hexagon was drawn correctly on the whiteboard and violated completely in the code.

This is the most common failure mode of hexagonal architecture, and it happens because people copy the picture instead of the rule. The hexagon shape is the least important thing Alistair Cockburn put in the original 2005 article. The important thing is one sentence: code that belongs to the inside of the application must not leak into the outside, and the inside must not depend on the outside. Everything else, the six sides, the ports, the adapters, the word "hexagonal" itself, is scaffolding around that one rule.

If you get the rule right, the folder structure is almost incidental. If you get it wrong, no amount of ports directories will save you.

Where the pattern actually came from

Cockburn first floated the idea on the WikiWikiWeb in the mid-90s, then wrote it up properly in 2005 and renamed it "Ports and Adapters." His own stated intent is worth quoting because it has nothing to do with hexagons: "Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."

The motivation was a pair of problems he saw repeated across organizations. On one side, business logic kept leaking into the user interface, which made the system impossible to test with automated suites and impossible to run headless. On the other side, business logic kept getting tied to a specific database, so when the database went down or got replaced, work stopped. Cockburn's insight was that these are the same problem wearing two costumes. The fix is not "add another layer and promise to keep it clean." Teams do that, and a few years later the new layer is full of business logic again, because there is no mechanism enforcing the promise.

The real asymmetry to exploit, he argued, is not top-versus-bottom or UI-versus-database. It is inside-versus-outside. The application sits in the middle and talks to the outside world through ports. A port is a purposeful conversation, expressed as an interface. An adapter is a piece of technology-specific code that makes some real thing, a REST handler, a SQL client, a test harness, fit that interface. The hexagon is just a drawing that gives you room to put more than two ports around the edge, instead of the one-dimensional stack a layered diagram forces on you.

The rule, in one piece of code

Here is the dependency rule made concrete. Start with the domain. It owns the interface it needs, and it imports nothing from the outside.

# domain/pricing.py  -- the inside. No web, no SQL, no framework imports.
from typing import Protocol
class RateRepository(Protocol):
    def rate_for(self, amount: float) -> float: ...
class Pricing:
    def __init__(self, rates: RateRepository):
        self._rates = rates
    def discount(self, amount: float) -> float:
        return amount * self._rates.rate_for(amount)

RateRepository is a port. Notice who owns it: the domain. The domain declares "I need something that can give me a rate for an amount," and that is the entire contract. It does not know whether the rate comes from Postgres, a CSV, a pricing microservice, or a hardcoded table in a test.

The adapter lives on the outside and depends inward. It imports the domain's port and implements it.

# adapters/postgres_rates.py  -- the outside. Knows SQL, depends on the domain.
from domain.pricing import RateRepository
class PostgresRateRepository:   # structurally satisfies RateRepository
    def __init__(self, conn):
        self._conn = conn
    def rate_for(self, amount: float) -> float:
        row = self._conn.execute(
            "SELECT rate FROM rates WHERE :a BETWEEN lo AND hi", {"a": amount}
        ).fetchone()
        return row["rate"]

The arrow points the right way. adapters imports domain. domain imports nothing. That single direction is the whole pattern. If you ever find an import in your domain folder that points at a database driver, a web framework, or a JSON serializer, you have broken hexagonal architecture, no matter what your folders are called.

The payoff shows up immediately in tests. Because the port is owned by the domain, the test substitutes its own adapter with zero ceremony:

def test_discount_uses_rate():
    fake = type("R", (), {"rate_for": lambda self, a: 0.05})()
    assert Pricing(fake).discount(200) == 10.0

No database. No mocking framework. No fixtures. This is exactly the property Cockburn was after in 2005: run the application in fully isolated mode against an in-memory substitute for the database.

Two kinds of ports, and why it matters

Cockburn split ports into two flavors, and skipping this distinction is where a lot of "hexagonal" code turns into mud. He called them primary and secondary, or driving and driven.

A driving port is an entry point. Something outside calls in to make the application do work: an HTTP handler, a CLI command, a message consumer, a test script. A driven port is an exit point. The application calls out to get something done: a database, an email gateway, a payment provider, another service. The difference is who starts the conversation.

In practice the two are wired differently. A driving adapter holds a reference to the application and calls it. A driven adapter is held by the application and gets called. Here is the same Pricing core, driven by two different primary adapters and backed by a swappable secondary adapter:

# adapters/http_api.py  -- a driving (primary) adapter
def make_discount_route(pricing: Pricing):
    def handle(request):
        amount = float(request.query["amount"])
        return {"discount": pricing.discount(amount)}
    return handle
# adapters/cli.py  -- another driving adapter, same core
def main(pricing: Pricing, argv):
    print(pricing.discount(float(argv[1])))

Nothing in Pricing changed to support both a web API and a command line tool, and nothing would change to add a cron job or a queue consumer. The composition root, the one place that knows about everything, is the only file allowed to touch both sides:

# main.py  -- the composition root, the only "dirty" file
conn = connect(os.environ["DATABASE_URL"])
pricing = Pricing(PostgresRateRepository(conn))   # wire driven adapter
app.route("/discount", make_discount_route(pricing))  # wire driving adapter

This is where the testing strategy from real systems comes from. Netflix wrote about rebuilding a service on hexagonal principles specifically so they could swap data sources without touching business logic. Their core logic lives in what they call interactors, and because those interactors depend only on ports, the same interactor can be triggered by a controller, an event, or a cron job. Their test suite is mostly interactor tests with the repository ports mocked, and those tests form the majority precisely because they are cheap and fast. That is not a coincidence of their cleverness. It is the mechanical consequence of pointing every dependency inward.

Where teams get it wrong

The folder-copying failure I opened with is the big one, but it has specific shapes worth naming.

The first is the leaky port. Your UserRepository port returns an ORM entity, or accepts a framework request object, or hands back a database row. The moment a port's signature mentions a type that belongs to the outside, the outside has leaked in, and your domain now transitively depends on the thing the port was supposed to hide. The fix is boring and non-negotiable: ports speak in domain types only. The Postgres adapter maps the row to a domain object on the way in and back to columns on the way out. Yes, that mapping is real work. It is the cost of the isolation, and it is the line most teams refuse to pay, which is why their hexagon is decorative.

The second is the port explosion. Cockburn warned about this directly: at one extreme you give every use case its own port and end up with hundreds of them. He found two, three, or four ports natural for real applications, four being the most he had encountered. A weather-alert system he describes has exactly four: the feed, the administrator, the subscribers, and the subscriber database. If you have fifteen ports, you have probably confused "every class gets an interface" with the pattern.

The third is mistaking the adapter for the logic. An adapter should be thin. It translates between a protocol and a port and does nothing else. When you see validation rules, pricing decisions, or workflow branching inside an HTTP handler, the business logic has crept back into the presentation layer, which is the exact disease the pattern was invented to cure. Cockburn's framing is useful here: the adapter is an instance of the Gang of Four Adapter pattern, "convert the interface of a class into another interface clients expect," and nothing more.

Yes, but: this is not free, and not always worth it

The honest objection is that hexagonal architecture adds indirection, and indirection has a cost. You write a port, an adapter, and usually a mapper between your domain model and your persistence model. For an application that is genuinely a thin layer over a database, a CRUD admin tool, an internal dashboard, a prototype you will throw away, that ceremony buys you isolation you will never use. You will swap your database approximately never, and your "business logic" is three if-statements. Layered architecture, or even just controllers calling an ORM directly, is the right call there. Adopting ports and adapters for a CRUD app is how you get the worst of both worlds: the boilerplate of hexagonal with the domain richness of a spreadsheet.

The pattern earns its keep when the inside is valuable and the outside is volatile. Complex domain logic that you need to test exhaustively and fast. Integrations you expect to swap, a payment provider, a notification channel, a data source that might move from one store to another. Multiple entry points into the same logic, a web API plus a CLI plus a queue consumer. If none of those describe your system, the hexagon is overhead wearing the costume of architecture. Match the pattern to the volatility, not to the blog post.

The takeaway

Forget the drawing. The test for whether you actually have hexagonal architecture is one experiment you can run on Monday: try to unit-test your core business logic without importing your web framework or your database driver. Not "with a test database." Without them on the import path at all. If you can construct your domain object, hand it a fake port written in three lines, and assert on the result, you have it. If that requires a running Postgres, a mock framework, and a Spring or FastAPI context, you do not, regardless of how many folders are named ports.

The rule is the pattern. Dependencies point inward. The domain owns its interfaces and imports nothing from the outside. Adapters are thin and live on the edge. The hexagon was only ever a way to give yourself room on the whiteboard for more than two of them. Draw a pentagon if you like. Just make the arrows point the right way.

Sources

Keep Reading