· 8 min read · updated

Hexagonal Architecture and Domain-Driven Design, Together

Hexagonal architecture is usually introduced as a picture: a six-sided shape with business logic in the middle and databases, queues and web frameworks arranged around the outside. The picture is fine and it teaches the wrong lesson, because it looks like a folder layout. It is not one. The hexagon is a single rule about which direction dependencies are allowed to point, and every benefit people attribute to the shape comes from that rule being enforced rather than drawn.

Domain-Driven Design and hexagonal architecture get mentioned together so often that they read as one idea. They are not. Hexagonal architecture tells you where the boundary goes and says nothing about what lives inside it. DDD tells you how to model what lives inside and says nothing about how it reaches a database. They compose well because they answer different questions.

This article separates the two: what a port is and why driving and driven ports are not the same kind of object, what an adapter is allowed to know, where aggregates and bounded contexts attach to the shape, how to enforce the dependency rule with a tool instead of a code review, what the indirection costs in files and mapping code, and the project size below which none of it pays for itself.

Hexagonal (ports and adapters) architecture with core business logic at the center and external interfaces surrounding it

Six sides because Alistair Cockburn needed room to draw more than four adapters, not because six means anything.

The hexagon is a dependency rule, not a shape#

Strip the drawing away and one sentence remains: code inside the boundary must not reference code outside it.

Not "should avoid". Must not. Your domain classes do not import the ORM, do not import the HTTP framework, do not import the message broker client. When the inside needs something the outside provides, it declares an interface describing what it needs, and something on the outside implements that interface.

That single constraint produces every property people claim for the architecture. The domain is testable without a database because it never mentions one. The framework is replaceable because nothing inside depends on it. The system runs in a test harness, a web request and a queue consumer because none of those are named in the part that holds the rules.

Everything else is decoration.

Driving ports and driven ports are different objects#

The word "port" covering two unrelated things is where most implementations go wrong.

A driven port is an interface the domain declares because it needs something. A repository. A clock. A payment gateway. The domain owns the interface, defines it in domain vocabulary, and an adapter on the outside implements it. Dependency inversion is the whole mechanism here.

A driving port is an interface describing what the application can be asked to do. A use case. The domain owns this one too, but nothing inverts: the outside calls in, and a controller or a console command or a message handler is what calls it.

HTTP controller

Driving port: DisburseLoan

Console command

Message consumer

Domain model

Driven port: LoanRepository

Doctrine adapter

In-memory adapter

Read the arrows. Every one of them points inward or stays inside. There is no arrow from the domain to Doctrine, which is the property the whole exercise exists to produce.

What an adapter is allowed to know#

An adapter knows both worlds by definition. That is its job and also its risk, because an adapter that leaks its own vocabulary inward quietly cancels the boundary.

Here is a driven port, written in the language of the business rather than the language of storage:

interface LoanRepository
{
    public function get(LoanId $id): Loan;

    public function save(Loan $loan): void;
}

Two things are absent and their absence is deliberate. There is no findBy(array $criteria), because that is a query language leaking through an interface the domain owns. And there is no flush(), because transaction control belongs to the layer that knows what a unit of work is, not to the model.

The adapter is where the storage vocabulary is allowed to exist:

final class DoctrineLoanRepository implements LoanRepository
{
    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    public function get(LoanId $id): Loan
    {
        $loan = $this->em->find(Loan::class, $id->toString());

        if ($loan === null) {
            throw LoanNotFound::withId($id);
        }

        return $loan;
    }

    public function save(Loan $loan): void
    {
        $this->em->persist($loan);
    }
}

The gloss that matters: LoanNotFound is a domain exception, not a Doctrine one. The adapter translates a null return into a failure the domain has a word for. If get() threw EntityNotFoundException instead, every caller inside the hexagon would now know that Doctrine exists, and the boundary would be decorative.

Where DDD attaches#

Hexagonal architecture will happily wrap a hexagon around 4,000 lines of procedural code. It does not care. You get the testability and none of the modelling benefit, which is the shape most "we do hexagonal" codebases are actually in.

DDD is what fills the inside, and it attaches at three points.

  • The aggregate is the consistency boundary. One aggregate, one transaction, one set of invariants that must hold at commit. This is the rule that decides how big a driving port's job is.
  • The bounded context is the hexagon. One context, one model, one hexagon, one deployable if you want it. Two contexts sharing a hexagon is how a model ends up meaning two things.
  • The ubiquitous language names the ports. LoanRepository and DisburseLoan are business terms. LoanDataAccessObject and LoanService are not, and the drift shows up in the interface names before it shows up anywhere else.

The relationship runs one way. DDD without hexagonal architecture is a well-modelled domain welded to a framework. Hexagonal architecture without DDD is a clean boundary around a mess. You want both, and they are not the same purchase.

Enforce the rule with a tool, not a review#

The dependency rule is one line of prose and it survives about six weeks of a team under deadline. Somebody imports the ORM into a domain class to fix a bug on a Friday, the test suite still passes, and nothing in the process notices.

In PHP, Deptrac turns the rule into a build failure:

# deptrac.yaml
deptrac:
  layers:
    - name: Domain
      collectors:
        - type: directory
          value: src/Domain/.*
    - name: Infrastructure
      collectors:
        - type: directory
          value: src/Infrastructure/.*
  ruleset:
    Domain: ~          # the domain may depend on nothing
    Infrastructure:
      - Domain

The ~ is the load-bearing character. It says the domain layer is allowed to depend on no other layer, so any import of anything outside it fails CI.

An architecture that is only true when everyone remembers it is not true. Java teams get the same guarantee from ArchUnit, .NET from NetArchTest. Pick whichever one your build already runs.

What it costs#

Every recommendation here has a bill attached, and this one is paid in indirection.

CostWhat it looks like
More filesAn interface, an implementation and a fake for every outbound dependency
Mapping codeDomain objects and persistence rows stop being the same shape
Slower first featureThe boundary has to exist before anything crosses it
Harder onboarding"Where does this code go" has a real answer that must be learned
Tempting shortcutsThe wrong use statement is always the fastest fix

None of that is an argument against the pattern. It is the price, and the price is worth paying when the domain rules are complicated enough that protecting them saves more than the indirection costs.

When not to build this#

The honest answer is that most software should not.

A CRUD application whose business rules are "the field is required" gets nothing from a hexagon. The model has no invariants worth protecting, the ports wrap a single ORM you are never replacing, and you have bought five layers of indirection to defend logic that fits in a form request. Use the framework the way it wants to be used. That is not a compromise, it is a correct reading of the problem.

The threshold I use has three parts, and it needs all three. The rules have to be complicated enough that a domain expert corrects you when you describe them back. The system has to be expected to outlive at least one of its infrastructure choices. And the team has to be able to name the bounded contexts without arguing, because a boundary drawn in the wrong place costs more than no boundary at all.

Below that line, the pattern is cargo cult with a nice diagram.

Two things this article deliberately does not settle. It does not tell you whether your aggregates are the right size, which is a modelling question no architecture diagram can answer. And it does not cover the read side, where going through the aggregate to render a list view is a well-known way to make a fast query slow.

Where the code lives#

The implementation is a separate exercise from the concepts, and I have written it out in full rather than in fragments. The DDD, CQRS and Hexagonal Architecture in Symfony 8 series builds one money-lending domain across six parts, with a companion repository and the costs measured at the end rather than assumed at the start. It starts with the domain model and the only rule that matters.

If you want the shorter version of the "should we" question first, I have argued elsewhere that DDD is not a one-size-fits-all solution.

author

Krzysztof Słomka

Senior Backend Engineer & Software Architect. Writing about backend architecture, DDD, Event Sourcing, distributed systems and AI engineering.

linkedin · github

$ related posts