الدرس 2 من 2
Living With Ports and Adapters
Where hexagonal thinking pays for itself, how it relates to clean and onion, and the practical questions it does not answer.
Hexagonal, clean and onion architecture are not competitors and choosing between them is largely a vocabulary decision. All three put technology-free business logic at the centre and push technology to the edge behind interfaces the centre owns. What differs is emphasis, and knowing which emphasis you need is more useful than picking a name.
The same idea, three emphases
Hexagonal (ports and adapters)
Emphasises the edge: what drives the application and what the application drives, with symmetry between a user interface and a test harness.
- The clearest guidance for testability and automation
- Makes multiple delivery mechanisms an explicit design goal
- Small vocabulary: two concepts and one rule
- Says little about how to organise the inside of the application
- Silent on entity design and on domain modelling
Choose when: Your pain is integration, testability or supporting several ways in — an API, a job, a command line, an event consumer.
Clean or onion
Emphasises the inside: concentric rings, an explicit place for entities and for use cases, and explicit rules about what crosses each boundary.
- Gives structure to the core, not only to the edge
- Names the use case as a unit, which helps large teams
- Pairs naturally with domain-driven design
- Heavier ceremony, especially the full presenter machinery
- Easier to implement as a folder layout without the property
Choose when: Your pain is complexity inside the business rules, or you need a structure a growing team can follow uniformly.
What the pattern does not decide for you
- **How to model the domain.** Hexagonal will happily host an anaemic domain. It tells you where the business logic goes, not how to write it — that is the subject of the domain-driven design module later in this chapter.
- **Where transactions belong.** A driven port that returns a transaction handle leaks infrastructure; one that hides transactions makes multi-repository operations impossible to compose. Most teams settle on an application-layer transaction that repositories join, and it is worth deciding explicitly rather than discovering the answer.
- **How to keep fakes faithful.** An in-memory adapter that behaves differently from the real one under concurrency or ordering will let broken code pass. Contract tests — the same test suite run against both the fake and the real adapter — are the usual answer and they are extra work.
- **Whether a port is worth having.** The pattern gives no stopping rule, which is why over-porting is its characteristic failure. The stopping rule has to come from outside: a namable second implementation, or a fake you actually need.
// One suite, two subjects. If the in-memory adapter diverges from Postgres,
// this fails rather than a production incident revealing it later.
export function enrolmentRepositoryContract(
name: string,
makeRepository: () => Promise<EnrolmentRepository>,
) {
describe(name, () => {
it('counts only active enrolments', async () => {
const repository = await makeRepository();
await repository.save(activeEnrolment('l-1'));
await repository.save(cancelledEnrolment('l-1'));
expect(await repository.activeEnrolmentCount(LearnerId.parse('l-1'))).toBe(1);
});
it('is idempotent on save of the same enrolment', async () => {
const repository = await makeRepository();
const enrolment = activeEnrolment('l-2');
await repository.save(enrolment);
await repository.save(enrolment);
expect(await repository.activeEnrolmentCount(LearnerId.parse('l-2'))).toBe(1);
});
});
}
enrolmentRepositoryContract('in-memory', async () => new InMemoryEnrolmentRepository());
enrolmentRepositoryContract('postgres', async () => new PostgresEnrolmentRepository(await testDb()));In practice
When the hexagon paid, and when it was theatre
A media company ran two systems built by the same team a year apart, both described as hexagonal. One was a content ingestion pipeline with six external integrations; the other was an internal admin tool over a single database.
Constraints
- Ingestion: six providers, three of which changed contract during the year
- Admin tool: one database, one user interface, twelve screens
- Same team, same conventions, same enforcement
- A year of change history available for both
Decision
Keep and extend the pattern in the ingestion pipeline. In the admin tool, remove the ports around the repository and the clock, keep the one around the export service, and call the database directly from the application services.
Why
The ingestion pipeline had exactly the problem the pattern solves — many edges, changing on their own schedules, and a strong need to test the pipeline without any of them. The admin tool had one edge that never changed, and its ports produced two extra files per feature and a fake that was less trustworthy than a test database in a container.
What it cost
The admin tool now cannot be tested without a database, so its test suite takes 40 seconds rather than 3. The team judged this acceptable for twelve screens and wrote down the trigger for reversing the decision: if a second data source appears, or if the suite passes two minutes, reintroduce the repository port. Recording the trigger is what makes this a deferred decision rather than an abandoned one.
Reviewing a proposal to "make the system hexagonal"
As a developer
Hears a structural improvement with a well-known shape and a clear definition of done: every external dependency behind a port, every adapter in its own directory.
As an architect
Hears a proposal with no stated problem, and asks which of three specific pains it is meant to fix — untestable business logic, an integration that keeps changing, or a second way in that is coming. If none of the three is present, the work will produce files rather than value. If one is present, the scope should be limited to the edges involved rather than applied to the whole system.
Your in-memory repository fake returns records in insertion order. The real Postgres repository returns them in whatever order the query planner chooses. A feature quietly depends on ordering. Where is the fault, and how do you prevent a recurrence?RevealHide
The fault is in the port, not in either adapter. The interface promised a collection without saying anything about order, and two implementations reasonably interpreted the silence differently — the same class of Liskov problem as the caching repository in Chapter 2. The prevention is twofold: state the ordering in the port explicitly, so both adapters must honour it and callers may rely on it, and then run one contract test suite against both adapters so that a divergence fails the build rather than production. The general lesson is that fakes are only useful to the extent that the port pins down behaviour, and anything a port leaves unsaid is a place where your fake and your reality will eventually disagree.
Key takeaways
- Hexagonal emphasises the edge; clean and onion emphasise the inside — most good codebases use both.
- The pattern does not decide domain modelling, transaction placement or how many ports to have.
- Contract tests run against both the fake and the real adapter are the practical defence against drift.
- Anything a port leaves unspecified is where two implementations will eventually disagree.
- Adopt ports where a real pain exists — testability, a changing integration, a second way in — not as a uniform policy.