Lesson 1 of 2
Ports, Adapters, and Which Side Drives
The pattern's original goal was an application that behaves identically whether a person, a test or a scheduled job is asking. That goal explains every part of its structure.
Alistair Cockburn described hexagonal architecture in 2005 with a specific complaint in mind: business logic kept leaking into user interfaces, and applications could not be tested or automated without one. His fix was to insist that the application expose its capabilities through explicit ports, and that every external thing — the web interface, the test harness, the database, the payment provider — reach it only through an adapter.
The hexagon shape carries no meaning beyond "more than four sides, so stop thinking in layers". What does carry meaning is that the ports fall into two groups with completely different design rules — and this is the part that summaries usually flatten.
Driving and driven sides of one application
Left-hand actors drive the application. Right-hand systems are driven by it. The interfaces on the two sides are owned differently.
On the left, three driving adapters — a web controller, a scheduled job and an acceptance-test harness — all call the same driving port. In the centre, the application core with its use cases and domain. On the right, three driven adapters — a Postgres repository, an email sender and a payment provider — each implementing a driven port that the core declares.
Driving adapters
Web controller
Scheduled job
Test harness
Application core
Driving ports
the capabilities offered
Use cases and domain
no technology here
Driven ports
the capabilities needed
Driven adapters
Postgres repository
Email sender
Payment provider
| Question | Driving (primary) port | Driven (secondary) port |
|---|---|---|
| Who defines it | The application, describing what it can do | The application, describing what it needs |
| Who implements it | The application itself | An outside adapter |
| Who calls it | An outside adapter — controller, job, test | The application |
| Typical example | `EnrolLearner.execute(input)` | `EnrolmentRepository.save(enrolment)` |
| What replacing the adapter buys | A new way to reach the same behaviour | A new technology behind the same behaviour |
// Driving port: what the application offers. Owned by the application.
export interface EnrolLearnerPort {
execute(input: { learnerId: string; courseId: string }): Promise<EnrolOutput>;
}
// Driven port: what the application needs. Also owned by the application.
export interface EnrolmentRepository {
activeEnrolmentCount(learnerId: LearnerId): Promise<number>;
save(enrolment: Enrolment): Promise<void>;
}
// Driving adapter 1 — HTTP.
export async function POST(request: Request) {
const output = await enrolLearner().execute(await request.json());
return Response.json(output);
}
// Driving adapter 2 — a nightly job. Same port, no HTTP anywhere.
export async function backfillEnrolments(rows: readonly PendingRow[]) {
for (const row of rows) await enrolLearner().execute(row);
}
// Driving adapter 3 — an acceptance test, with driven ports faked.
const app = new EnrolLearner(new InMemoryEnrolmentRepository(), fixedClock('2026-03-01'));
await app.execute({ learnerId: 'l-1', courseId: 'c-1' });In practice
A port that earned its keep during an outage
A ticketing platform sent all transactional email through one provider, behind a driven port with three methods: send a templated message, check delivery status, and suppress an address. The provider had a six-hour regional outage on the morning of a major on-sale.
Constraints
- Roughly 40,000 confirmation emails expected during the on-sale
- A secondary provider account existed but had never been used
- Templates lived in the primary provider's system
- The team had 90 minutes before the on-sale opened
Decision
Write a second adapter against the existing port using the secondary provider, with templates rendered in the application rather than by the provider, and switch by configuration.
Why
The port described what the application needed, not what the provider offered, so the second adapter had only three methods to satisfy. Rendering templates in the application had been a deliberate earlier choice, made specifically so that provider-side templates would not become an invisible dependency — which is what made the 90-minute window feasible.
What it cost
The secondary provider had no delivery-status webhook, so the status method returned "unknown" and one internal dashboard degraded for a day. The team accepted a degraded dashboard over 40,000 undelivered confirmations, and afterwards recorded that the port's status method was the weakest part of the abstraction because it assumed a capability not every provider has — a small Liskov problem they chose to live with rather than remove.
Asked how many ports the system should have
As a developer
Counts external systems and creates one port each, since that is the pattern applied consistently and each port makes its dependency mockable in tests.
As an architect
Counts the capabilities the application needs, which is usually a smaller number than the systems it talks to and occasionally a larger one. Three services might sit behind one `Notifications` port if the application only ever asks "tell this person this thing", while a single database might sit behind three ports if three modules need genuinely different things from it. The port boundary follows the application's vocabulary, not the vendor list.
Your team wraps the database, the cache, the logger, the clock, the file store and the feature-flag service each in its own port, then finds that most tests use the real implementations anyway because the fakes are hard to keep faithful. What went wrong?RevealHide
Ports were created by counting external systems rather than by naming capabilities the application genuinely needs to vary or fake. Two symptoms follow. Fakes that must reproduce complicated behaviour — a cache with eviction, a file store with partial writes — drift from the real thing, so tests pass against a fiction; that is why teams quietly go back to the real implementation. And ports for things with one obvious implementation, such as a logger, buy nothing at all. The productive move is to keep ports where a second implementation exists or where the fake is trivially faithful — a clock, a repository, an email sender — and call the rest directly, exactly as the dependency-inversion lesson in Chapter 2 argued.
Key takeaways
- A port is an application-owned interface; an adapter is technology-specific code that plugs into one.
- Driving ports are implemented by the application and called from outside; driven ports are the reverse.
- The pattern is working when a full business scenario can be driven with every driven port faked.
- A driven port that mirrors the vendor SDK is a wrapper, not an abstraction.
- Count capabilities, not external systems, when deciding how many ports to have.