Ports Are Requirements, Not Sockets: Naming the Interfaces Your Core Owns
Ask five engineers what a port is and you will hear five vague answers about "the boundary between domain and framework." Alistair Cockburn's hexagonal architecture is more precise than its reputation: a port is an interface your application core writes because it needs a capability it does not have. Not an interface the framework hands you — one you own, name in business language, and force the outside world to implement. Everything else in the style follows from that ownership.
Driving and driven: the two sides of the hexagon
Every application has two kinds of interaction. Driving (primary) ports are how the outside world invokes your use cases: an HTTP controller, a CLI command, a scheduled job, a message consumer. Driven (secondary) ports are what your use cases need back from the world: persistence, payments, email, a clock, a feature-flag source. The hexagon is the shape people draw, but the load-bearing idea is simply that the core defines interfaces in both directions and knows about neither end.
Here is a real core. The application wants to place an order; it needs to price lines, charge a card, save the order, and emit an event:
// application port — driven side, owned by the core
public interface PaymentGateway {
ChargeResult charge(CustomerId customer, Money amount, IdempotencyKey key);
}
public interface OrderRepository {
void save(Order order);
Optional<Order> find(OrderId id);
}
// primary port — the use case itself, framework-free
public class PlaceOrder {
private final OrderRepository orders;
private final PaymentGateway payments;
private final ProductCatalog catalog;
private final Clock clock; // yes, time is a dependency too
public OrderId execute(PlaceOrderCmd cmd) {
Order order = Order.create(cmd.customerId(), catalog.price(cmd.lines()), clock.now());
ChargeResult result = payments.charge(order.customerId(), order.total(),
IdempotencyKey.of(cmd.requestId()));
require(result.isApproved(), "payment declined: " + result.reason());
orders.save(order);
return order.id();
}
}
Read the signature of execute and you have the full business transaction in one screen — no framework annotations, no HttpServletRequest, no JPA session. Ports are interfaces owned by the application core that describe required capabilities, and the tell of a good port is that you can read it without knowing whether the implementation will be Postgres, DynamoDB, or a HashMap.
Naming ports after capabilities, not technologies
The most common beginner mistake is ports named after the tech: MysqlOrderRepository, SqsClient, KafkaPublisher. Those are adapter names wearing a port's clothes, and they leak: MysqlOrderRepository invites methods like findBySqlWhere(String) that no other implementation could honor. Name the port for what the core requires:
public interface OrderStore { ... } // capability
public interface OutboxPublisher { ... } // guarantee: "at least once, ordered per aggregate"
public interface FraudRisk { RiskScore assess(Order o); } // question the core asks
Each port should carry its contract in the javadoc or a sibling contract test: consistency expectations (does save imply durability?), ordering, idempotency, latency budget. That contract is the real interface — the Java interface keyword is only its syntax.
The Clock port, and why purists insist on it
Clock looks pedantic until the test suite needs "order placed two minutes before midnight UTC" and the code has Instant.now() scattered through eleven classes. Injecting Clock (Java has it in the JDK; other languages wrap it) converts a time-dependent branch into a deterministic parameter. The same argument covers config flags, randomness, and UUID generation: any input the core does not generate is a port. This is the direct mechanism behind the third promise of this style — swapping an adapter for an in-memory fake makes integration tests fast and deterministic — which you will exercise fully in the testing chapter.
Where this differs from the layered architecture you already know
Layered says "web must not import SQL." Hexagonal says something stronger and more useful: the core imports nothing at all, and the composition root decides what plugs into each port. In layered, the domain declares repository interfaces and that is the end of it; in hexagonal, every seam is an interface you chose, including the primary side, which means the same use case can be driven by REST today and a batch importer tomorrow without editing a line inside.
The cost is real and should not be waved away: more files, indirection when you want to trace a call, and the discipline to keep framework types outside the core. Whether that cost pays depends on how often you expect to change the edges — a question the next chapter's test and swap story makes concrete. Continue to Modular Monolith to see how these seams look when several such cores live in one deployable; the style here also builds directly on Layered Architecture.
Wiring Real Technology: Adapters for Postgres, Stripe, and HTTP
The core from the previous chapter compiles, tests, and knows nothing. Now you connect it to reality: Postgres for orders, Stripe for payments, and an HTTP controller for customers. In hexagonal terms, you write adapters — classes that sit at the edge, implement your ports in some technology's dialect, and are the only code in the system allowed to import frameworks.
The anatomy of a driven adapter
An adapter has three jobs: translate the port's types into the technology's types, honor the port's contract, and fail in a way the core can understand. Here is the Postgres one:
public class JdbcOrderRepository implements OrderRepository {
private final JdbcTemplate jdbc; // Spring's, or plain JDBC — the core does not care
@Override
public void save(Order order) {
jdbc.update("""
INSERT INTO orders (id, customer_id, status, total_cents, placed_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status
""",
order.id().value(), order.customerId().value(),
order.status().name(), order.total().cents(), order.placedAt());
// domain events are NOT published here — see the outbox chapter of your roadmap
}
}
Notice what the adapter does not do: no pricing, no validation of business rules, no policy decisions. An adapter translates; it never decides. When the persistence schema needs a default for a legacy row, that is a translation decision and it belongs here. When the schema cannot represent a new invariant, the correct move is to change the schema — not to smuggle business compensation into the adapter.
The Stripe adapter carries a subtlety worth internalizing because it is where money bugs come from:
public class StripePaymentGateway implements PaymentGateway {
public ChargeResult charge(CustomerId c, Money amount, IdempotencyKey key) {
try {
Charge ch = stripe.charges.create(toStripe(c, amount), key.value());
return ChargeResult.approved(ch.id());
} catch (StripeCardDeclined e) {
return ChargeResult.declined(e.getCode()); // business outcome, not an exception
} catch (StripeTimeout e) {
throw new UncertainOutcomeException("charge " + key + " state unknown", e);
}
}
}
Declines are ordinary business results; timeouts are not — after a timeout the charge may or may not have gone through, and the honest port contract says so (UncertainOutcomeException). An adapter that converts every failure to Optional.empty() destroys the core's ability to behave correctly under partial failure.
Driving adapters and the composition root
On the primary side, the adapter calls inward:
@RestController
@RequestMapping("/orders")
class OrderController {
private final PlaceOrder placeOrder; // the port, injected
@PostMapping
ResponseEntity<PlaceOrderResponse> place(@RequestBody PlaceOrderRequest req) {
OrderId id = placeOrder.execute(req.toCommand());
return ResponseEntity.created(URI.create("/orders/" + id))
.body(PlaceOrderResponse.of(id));
}
}
req.toCommand() is deliberate: the HTTP DTO never enters the core, so renaming a JSON field is a controller-only diff.
Everything is assembled in one place — a Spring @Configuration, a Guice module, or a main() in a plain codebase:
PlaceOrder placeOrder = new PlaceOrder(orderStore, stripeGateway, catalog, Clock.systemUTC());
That file is the composition root, and it is the only module allowed to import both core and adapters. Its value shows up the day someone says "run the whole order flow against an in-memory store for the load-test harness": you write one alternative wiring, and the harness uses the identical PlaceOrder logic.
The dependency picture, and the discipline that keeps it true
Arrows at compile time: adapters → core. Never core → adapters. The evidence you can point to in a PR is the import list — if PlaceOrder.java shows com.stripe or org.springframework on top, the hexagon has been violated even if the team still calls the design hexagonal. The same rule you used for layer enforcement applies here: packages for core, adapters/in, adapters/out, with an import-checking rule in CI, because an architecture that needs remembering is an architecture that decays.
Two honest costs belong on the record. First, indirection: tracing POST /orders to the Stripe call crosses three files instead of one, which newcomers pay until the naming clicks. Second, anemic adapters: teams sometimes move logic into adapters to keep the core "pure," recreating the service-layer dumping ground one level out. The core is where business decisions live; if a decision will not compile there, you have found a missing domain concept — exactly the diagnostic you practiced in the layered topic.
Interviews probe this style with "show me where the boundary is and how you keep it clean." The strong answer: ports are core-owned interfaces named for capabilities, adapters translate and never decide, one composition root wires, and CI import rules enforce. The payoff — deterministic tests with fakes swapped in for real infrastructure — is the next chapter's subject: Modular Monolith.
Swapping Postgres for a HashMap: Fast, Deterministic Tests and the Fakes That Lie
A team reports that their "unit" tests take nine minutes and need Docker. Investigating, you find PlaceOrderTest starting Testcontainers, waiting for Postgres to accept connections, creating a schema, and then asserting that a discount was applied. The business rule is being tested through three pieces of infrastructure, and the suite failure rate tracks the load on the CI machine. The fix is not faster Docker. The fix is to test the core against a fake adapter — which the ports you already own make trivial.
The in-memory adapter is twenty lines
public class InMemoryOrderStore implements OrderStore {
private final Map<OrderId, Order> rows = new ConcurrentHashMap<>();
@Override public void save(Order order) { rows.put(order.id(), order.copy()); }
@Override public Optional<Order> find(OrderId id) { return Optional.ofNullable(rows.get(id)); }
@Override public boolean exists(OrderId id) { return rows.containsKey(id); }
}
With InMemoryOrderStore, a scripted PaymentGateway that approves, declines, or throws UncertainOutcomeException on demand, and a Clock.fixed(...), the test for the whole place-order transaction is pure JVM — microseconds per case, no sockets, no flakiness:
@Test void vip_order_applies_discount_before_charge() {
var payments = new ScriptedPayments(ChargeResult.approved("ch_1"));
var place = new PlaceOrder(new InMemoryOrderStore(), payments, fixedCatalog(), Clock.fixed(T1));
place.execute(cmdWithVipCustomer());
assertEquals(Money.of(90), payments.lastChargeAmount()); // 10% off, asserted at the port
}
Two properties make this more than a convenience. First, the assertion happens at the port boundary, which is where the core's obligations actually end — you are testing exactly what the core promised. Second, you can force states real infrastructure makes hard: a payment gateway that succeeds and then times out (the uncertain-outcome branch), a clock straddling a month boundary, a repository that throws ConcurrencyConflict on the second save.
The catch: fakes lie, and your tests inherit the lie
An in-memory fake implements your understanding of the port. If OrderStore.save is contractually "durable after return" but the fake only mutates a map, the fake cannot catch a case where the real adapter batches writes and loses them on crash. Fakes also drift: someone adds findByStatus to the interface, the fake returns an empty list "for now," and three tests start asserting against fiction.
The standard remedy is a shared contract test suite: one class of tests written against the port's contract, run once per implementation.
@ParameterizedTest
@OrderStoreImplementations // discovers JdbcOrderRepository (Testcontainers) and InMemoryOrderStore
void save_then_find_returns_same_order(OrderStore store) { ... }
@Test jdbc_impl_survives_process_restart(@TempDir Path db) { ... } // in-memory variant skipped
The contract suite runs the same expectations against the fake in milliseconds and against the real adapter in a nightly or pre-merge job with Testcontainers. When the two disagree, the fake is fixed or the contract is clarified — the disagreement is information, not noise. Properties unique to one adapter (crash durability, SQL-specific query behavior) live in that adapter's own tests, not the shared suite.
What to fake, what to keep real
Not every dependency deserves a fake. A practical split:
| Dependency | Fake or real in core tests | Why |
|---|---|---|
| Persistence | In-memory fake | Speed; contract suite covers the real one |
| Payment, email, shipping | Scripted fake | You need failure cases real services will not produce |
| Clock, randomness, UUIDs | Injected fixed values | Determinism of boundaries and ordering |
| Serialization frameworks | Real, tested at the edge | A fake HTTP layer tests nothing about your DTOs |
| The adapters themselves | Real (Testcontainers, wiremock) | Their whole job is to honor the protocol |
That last row is the shape of the full test pyramid in hexagonal terms: fast core tests against fakes, adapter tests against real protocols, and a thin end-to-end suite driving one primary port through the real wiring.
Why this is a hiring-topic, not a trivia topic
"How do you make integration tests fast and deterministic?" is asked constantly, and the hexagonal answer is the one interviewers reward: the application owns ports, so swapping an adapter for an in-memory fake is a one-line change in the composition root; the core suite becomes pure computation; a shared contract test keeps the fake honest; the adapters themselves are still tested against the real technology. The follow-up they may press — "what does this cost?" — has an honest answer: more files, indirection for newcomers, and maintenance of the fakes. Teams that change infrastructure or need deterministic CI pay it gladly; teams that will not should weigh it against the simpler layering you saw in Layered Architecture. To see how multiple hexagonal cores coexist inside one deployable, continue to Modular Monolith.
Quiz
1. Which of these is a properly defined port in a hexagonal design?
2. The Stripe adapter catches a timeout from the payment API. What should it do, and why?
3. Why does the hexagonal test strategy use in-memory fakes plus a shared contract test suite, rather than fakes alone?
4. In a hexagonal codebase, where may framework imports (Spring annotations, JPA, vendor SDKs) legally appear?
Flashcards
Question
What distinguishes a driving (primary) port from a driven (secondary) port?
Click to reveal answer
Answer
Driving ports are the ways the outside world invokes use cases (HTTP controller, CLI, scheduler, message consumer); driven ports are the capabilities the core needs back from the world (persistence, payments, email, clock). Both are interfaces the core owns; the difference is direction of initiative.
Question
What three jobs does an adapter have, and what is the one thing it must never do?
Click to reveal answer
Answer
Translate the port's types into the technology's types, honor the port's contract (durability, ordering, idempotency), and fail in a way the core can understand. It must never decide business rules — logic smuggled into adapters recreates the service-layer dumping ground one level out.
Question
What is the composition root, and why is it exempt from the no-framework-imports rule?
Click to reveal answer
Answer
The single module (Spring @Configuration, a Guice module, or main()) that constructs the core's objects and injects concrete adapters. It must import both core and adapters to wire them; exempting exactly this place keeps every other file single-world. Swapping wirings (e.g., in-memory store for a load harness) changes only this file.
Question
Why inject Clock, randomness, and UUID generation as ports?
Click to reveal answer
Answer
They are inputs the core does not generate; without injection, time-dependent logic becomes untestable branches and flaky boundary tests. Injecting them converts 'two minutes before midnight UTC' from luck into a deterministic parameter of the test.
Question
How do you structure the test pyramid in a hexagonal codebase?
Click to reveal answer
Answer
Fast core tests against in-memory/scripted fakes with assertions at the port boundary; adapter tests against the real protocol (Testcontainers, WireMock) including one-off properties like crash durability; a thin end-to-end suite driving a primary port through the full real wiring. Shared contract suites run port expectations once per implementation.
Revision Notes
Key Takeaways
- 1. Ports are core-owned interfaces named for capabilities; the core imports nothing, and adapters are the only framework-aware code
- 2. Adapters translate and never decide; a timeout adapter that reports a definite outcome is lying and causes double charges
- 3. The composition root is the single legal meeting point of core and technology, so swapping a Postgres store for a HashMap changes one wiring line
- 4. Fakes give microsecond deterministic core tests, but they drift — shared contract tests run identical expectations against fake and real adapters
- 5. The style's cost is indirection and file count; whether that pays depends on how often the edges change
Interview Tips
- • 'What is a port and what is an adapter?' — port: interface the core owns, stated in business types, describing a required capability; adapter: the edge class implementing it for a technology, translating and never deciding
- • 'How do you make integration tests fast and deterministic?' — fake adapters in the composition root, assertions at the port boundary, fixed Clock, plus a contract suite that keeps fakes honest and still tests real adapters
- • 'Where does a framework belong?' — driving and driven adapters plus the composition root; if core imports Spring/JPA/Stripe, the hexagon is broken and CI import rules should fail the build
- • 'What do you do when a payment call times out?' — model the outcome as unknown, keep the idempotency key at the port contract, and let core logic decide retry-vs-manual-review; never fabricate a decline
Cheat Sheet
Hexagonal (Ports & Adapters) Cheat Sheet
Definition: a port is an interface the application core writes because it needs a capability it does not have. Adapters implement ports for real technologies.
| Side | Examples | Implements |
|---|---|---|
| Driving (primary) | REST controller, CLI, scheduler, MQ consumer | calls use cases |
| Driven (secondary) | Postgres repo, Stripe gateway, outbox publisher, Clock | used by use cases |
Naming test: port named for capability (OrderStore, PaymentGateway, FraudRisk) in core's own types — MysqlOrderRepository/StripeClient are adapter names.
Adapter rules: translate types, honor contract, surface uncertain outcomes honestly (timeout ≠ decline). Never decide business rules.
Wiring: one composition root imports core + adapters; every other file is single-world. CI import rules enforce (adapters → core only).
Test strategy:
core tests → fakes (InMemoryOrderStore, ScriptedPayments, fixed Clock) — µs, no sockets
contract tests → same port expectations × (fake, real adapter)
adapter tests → Testcontainers/WireMock for durability & protocol behavior
e2e → one full wiring, one happy path + one failure
Costs (state them): file count, indirection for newcomers, fake maintenance. Pay it when infrastructure changes or CI determinism matters.