The eligibility platform is used three ways. Applicants submit through a web portal. Employers send nightly enrollment batches. Support representatives reevaluate cases from an internal console. The application also depends on an identity provider, a state household-data service, a policy registry, a database, and a clock.
In the current implementation, the portal controller calls vendor SDKs directly. Unit tests need sandbox credentials. Batch processing duplicates part of the decision flow because the controller cannot run without HTTP state. When the household-data provider changes its error model, eligibility code changes beside retry and serialization code.
Hexagonal Architecture draws a boundary around the application and names its conversations with the outside world. A port describes what the application offers or needs. An adapter translates a particular technology or actor into that conversation.
Draw the actual hexagon
Alistair Cockburn chose a hexagon to avoid implying a top, bottom, or fixed stack of layers. The six sides are not six required components. The useful distinction is between actors that drive the application and systems the application drives.
Driving ports describe application capabilities:
interface SubmitEligibilityApplication {
submit(command: SubmitApplication): SubmissionResult
}
interface ReevaluateEligibility {
reevaluate(applicationId: Id, reason: RecheckReason): EligibilityDecision
}
The portal, batch importer, and support console are driving adapters. Each translates its transport into those capabilities. None owns the eligibility rules.
Driven ports describe what the application needs:
interface HouseholdFacts {
factsFor(applicant: ApplicantId, asOf: Date): Result<Household, FactsFailure>
}
interface DecisionJournal {
append(decision: EligibilityDecision): Result<JournalPosition, JournalFailure>
}
interface PolicyClock {
today(): Date
}
Vendor clients, persistence code, and the system clock are driven adapters. The port names an application need, not a technology. HouseholdFacts can survive a provider replacement; StateApiClient cannot pretend to be independent of the state API.
Use Cockburn's original test
Can the application run without its production user interface and production database? Can an automated test drive the same capabilities as the portal? Can a different adapter satisfy an external need without copying policy?
test "ninth dependent is evaluated as policy, not provider capacity" {
facts = InMemoryHouseholdFacts(householdWithDependents(9))
journal = RecordingDecisionJournal()
app = EligibilityApplication(facts, journal, FixedClock(2026-07-12))
decision = app.reevaluate(APPLICATION_42, POLICY_CHANGE)
assert decision == Eligible(...)
assert journal.recorded(decision)
}
This test is fast because it substitutes adapters. Its confidence has a boundary: it proves application behavior against the port contract. It does not prove that the production provider authenticates correctly, maps all fields, or behaves like the in-memory adapter.
Replacement without fantasy
A second household-data provider arrives with different pagination, rate limits, and error codes. The adapter should absorb technology differences that do not change the application's meaning. It should not erase meaningful semantic differences to preserve a stable interface at any cost.
If the original port assumes facts are complete and the new provider returns partial, confidence-scored observations, the application has learned something important. Revise the port to represent completeness or confidence. A lowest-common-denominator adapter that silently treats “unknown” as “no” makes the architecture look stable while corrupting policy.
The goal is not never changing the core. The goal is ensuring the core changes because application meaning changed, not because a vendor renamed an HTTP field.
Isolation needs integration evidence
Ports make isolated tests possible. Adapters make integration tests necessary. Every driven adapter should demonstrate:
- request and response serialization;
- authentication and credential refresh;
- timeout, retry, and rate-limit behavior;
- translation of provider failures into the port's failure model;
- idempotency and duplicate handling where applicable;
- compatibility with a provider sandbox, emulator, or recorded contract.
Driving adapters need evidence too. A portal controller must map validation errors and authorization correctly. A batch adapter must preserve row identity and report partial failure. The hexagon moves technology outward; it does not declare technology harmless.
What it buys
An application-shaped boundary can let developers work on policy without acquiring credentials or coordinating access to every shared sandbox. Multiple delivery mechanisms can invoke the same use cases instead of copying behavior. Provider incidents can be localized in an adapter, and a provider replacement has an explicit compatibility target.
These mechanisms can return time to building: shorter policy test loops, fewer waits on external environments, and smaller provider-change surfaces. They only do so when the port captures a stable application need and the adapters carry adequate integration evidence.
What it costs
Ports add interfaces and data shapes. Adapters add mapping code. Transactions crossing a database and an external service require an explicit design instead of hiding inside a call chain. A developer tracing one request may move between a driving adapter, use case, port, and driven adapter.
Common failure modes include:
- defining one port for every class instead of every meaningful conversation;
- naming ports after vendors or protocols rather than application capabilities;
- leaving business decisions in adapters because “integration is complicated”;
- using mocks as the only evidence that adapters satisfy a port;
- forcing materially different providers behind a misleading common contract;
- making transaction and retry ownership ambiguous at the boundary.
When not to use it
A one-off import job that reads one vendor file and writes one table may be clearest as a linear program. A stable utility with one integration does not need ports for its clock, logger, parser, and file system. A reporting path whose behavior is a database query should not hide the query behind a generic application abstraction.
For an MVP, a well-named provider module may be the smallest reversible choice. Extract a port when a second driver appears, provider volatility becomes costly, isolated policy work matters, or the integration boundary needs an independently testable contract.
Sources
- Alistair Cockburn, Hexagonal Architecture, the original ports-and-adapters description.
- Netflix Technology Blog, Ready for changes with Hexagonal Architecture, a production narrative about preparing Studio Workflows for changing services and data sources.
- AWS Prescriptive Guidance, Hexagonal architecture pattern, including benefits, applicability, and the cost of added complexity.
- Martin Fowler, DIP in the Wild, for dependency ownership across a boundary.
- Nicole Forsgren and colleagues, The SPACE of Developer Productivity, for evaluating efficiency and flow alongside satisfaction, communication, and outcomes.