· 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.

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.
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.
LoanRepositoryandDisburseLoanare business terms.LoanDataAccessObjectandLoanServiceare 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:
- DomainThe ~ 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.
| Cost | What it looks like |
|---|---|
| More files | An interface, an implementation and a fake for every outbound dependency |
| Mapping code | Domain objects and persistence rows stop being the same shape |
| Slower first feature | The boundary has to exist before anything crosses it |
| Harder onboarding | "Where does this code go" has a real answer that must be learned |
| Tempting shortcuts | The 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.
$ related posts
18 min readNEW
The Application Layer, the Transaction, and a Working API
Application layer in Symfony 8: use cases on a Messenger command bus, the transaction boundary in config, optimistic locking and RFC 9457 errors. Part 2.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 2#ddd#hexagonal-architecture#symfony#php#cqrs#messenger#software-architecture15 min readNEW
The Domain Model, and the Only Rule That Matters
DDD domain model in PHP 8.5 and Symfony 8: keep the container out of the domain, and choose Doctrine ORM or DBAL with the costs on the table. Series part 1.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 1#ddd#hexagonal-architecture#symfony#php#doctrine#software-architecture15 min readNEW
The Read Side Does Not Go Through the Aggregate
CQRS read models in Symfony 8: a read port the domain does not own, a query bus that returns a value, and when a projection table earns its place. Part 3.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 3#cqrs#ddd#hexagonal-architecture#symfony#php#postgresql#doctrine