All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

Hexagonal architecture (ports and adapters): what it is and when to use it

What changes in your code with hexagonal architecture, what ports and adapters look like in practice, and when the pattern is worth the complexity.

Hexagonal architecture (ports and adapters): what it is and when to use it
COVER · Tutorials

You open OrderService to swap Postgres for another provider and discover the business rule calls the ORM directly, builds SQL in the middle of a discount calculation, and fires an email before saving. Changing databases just became a three-week project with regression risk everywhere. That coupling between "what the system does" and "how it talks to the outside world" is exactly the pain hexagonal architecture — ports and adapters — sets out to solve.

This is a hands-on guide: what actually changes in your code, what ports and adapters look like in real projects, and — most importantly — when this structure pays for the cost it charges.

The core idea in one sentence

Alistair Cockburn coined the term to fight a concrete problem: business logic leaking into the interface, and the interface (screen, database, queue) leaking into the logic. Hexagonal architecture puts the domain at the center and says it only talks to the outside world through ports — interfaces the domain itself defines. Everything that is concrete technology (HTTP, SQL, Kafka, SMTP) lives outside, in adapters that implement those ports.

The "hexagon" has nothing magical about the number six. The shape is hexagonal only to suggest there are many sides for input and output, not the traditional "top = UI, bottom = database." There is one golden rule: dependencies point inward. The domain never imports an adapter; the adapter is the one that knows the domain.

Primary and secondary ports

The most common confusion is thinking every port is "a repository interface." There are two kinds, and direction matters:

  • Primary ports (driving / inbound): what the outside world can ask the domain to do. Usually the use cases. An HTTP controller, a queue handler, or a CLI command calls this port.
  • Secondary ports (driven / outbound): what the domain needs from the outside world. Persistence, sending email, payment gateway. The domain declares the interface; the adapter implements it.

In PHP, the secondary persistence port is just an interface declared in the domain:

// Domain — knows nothing about Postgres, nothing about Doctrine
namespace App\Domain\Order;

interface OrderRepository
{
    public function save(Order $order): void;
    public function ofId(OrderId $id): ?Order;
}

The adapter lives far away, at the edge, knowing the technology:

// Secondary adapter — knows the domain; the domain does NOT know it
namespace App\Infrastructure\Persistence;

use App\Domain\Order\OrderRepository;

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(private EntityManagerInterface $em) {}

    public function save(Order $order): void
    {
        $this->em->persist(OrderMapper::toEntity($order));
        $this->em->flush();
    }
    // ...
}

The use case (primary port) orchestrates without knowing which database runs underneath:

final class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,   // secondary port
        private PaymentGateway $payments,  // secondary port
    ) {}

    public function __invoke(PlaceOrderCommand $cmd): OrderId
    {
        $order = Order::place($cmd->items, $cmd->customerId);
        $this->payments->charge($order->total(), $cmd->card);
        $this->orders->save($order);
        return $order->id();
    }
}

Swapping Doctrine for an in-memory repository in tests, or for another ORM in production, doesn't touch a single line of PlaceOrder. That's the concrete win.

What it actually solves

The most tangible benefit is not "swapping databases" — that almost never happens. It's testability. With secondary ports backed by fake implementations, you test the entire business rule without spinning up Postgres, Kafka, or the network. Suites that ran in 40 seconds now run in 400 milliseconds and stop failing from infrastructure flakiness.

The second win is deferring decisions. You model the domain before committing to whether the cache is Redis or the queue is SQS. The port freezes the contract; the adapter comes later. On projects where infra is still under debate, that's liberating.

The third is boundary clarity: when someone opens the Domain package, there isn't a single framework import. That forces the team to keep business rules expressed in business terms.

The cost nobody puts on the slide

Hexagonal isn't free. Each secondary port becomes at least an interface, a real implementation, a fake for tests, and often a mapper between the domain model and the persistence entity. For a product-catalog CRUD, that's pure ceremony: you created four files to run one INSERT.

Hexagonal architecture shares this trade-off with its better-known cousin — and if you want to understand why dependency inversion helps and where it becomes theater, it's worth reading Clean Architecture: benefits and pitfalls, which attacks the same problem from the angle of concentric layers. The difference in emphasis is useful: hexagonal talks about direction (inbound vs. outbound) and swapping adapters; Clean talks about layers and the Dependency Rule. In practice you end up mixing both vocabularies — and that's fine.

The real risk is over-engineering. Creating a port for everything, including what will never be swapped, is the shortest path to a codebase where opening a file means hopping through six indirections to find the line that actually does something.

When it's worth it and when it isn't

It's worth it when you have rich domain logic (rules, invariants, state machines) that needs to survive technology changes, and when testability is a priority — financial, tax, ordering systems, anything where a bug is expensive.

It's not worth it for a thin CRUD, a backend-for-frontend that just relays calls, a script, or an MVP you're not even sure will exist in three months. In those cases the domain is so anemic that the port protects nothing — it protects the void.

An honest heuristic: apply ports and adapters only at the boundary that hurts. If the database never changes but the payment integration has already switched providers twice, put a port on payments and let persistence use the ORM directly. Architecture is about where to spend complexity, not about applying the whole pattern everywhere.

Frequently asked questions

What is hexagonal architecture in simple terms?

It's a way of organizing code so the business rule (the "center") doesn't depend on external details like the database, web framework, or queue. The center defines interfaces (ports) and the outside world implements them (adapters), letting you swap technology without rewriting the logic.

When should I use ports and adapters?

When there's meaningful domain logic that needs to be tested quickly and in isolation, or when technological edges change frequently. For simple CRUDs and throwaway MVPs, the cost of indirection outweighs the benefit.

What's the difference between hexagonal architecture and Clean Architecture?

They're cousins defending the same idea (dependencies pointing toward the domain) with different vocabularies. Hexagonal emphasizes inbound and outbound ports and swapping adapters; Clean organizes everything into concentric layers with the Dependency Rule. In practice, teams combine both concepts.

How many "sides" does the hexagon have?

The number six is irrelevant — it's just a drawing. The hexagon merely illustrates that there are several input and output points, not just "UI on top, database at the bottom."

What to take from this guide

Hexagonal architecture is not a promise of "easy database swaps"; it's a discipline for keeping business rules clean of technical detail and cheap to test. The value lives in the ports you'll actually exercise — tests, unstable integrations, deferred decisions. Apply it at the boundaries that hurt, resist the urge to make an interface for everything, and weigh the cost of indirection against the real gain. A good pattern is one you can defend face-to-face to the dev who'll maintain it a year from now.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile