Hexagonal architecture in practice

Hexagonal architecture starts from a simple idea: the business core of an application should know nothing about the database, the web framework, or the messaging system used around it. This separation, often perceived as an extra constraint, becomes an accelerator as soon as a project outgrows a few weeks of development.
Ports and adapters, in one sentence
The domain defines interfaces (the ports) describing what it needs,
without saying how it's implemented: "I need to save a mission" rather than
"I need a SQL INSERT". Adapters then implement those interfaces with a
concrete technology: a Postgres database today, a plain JSON file in tests,
an external API tomorrow if the need changes.
What matters is the direction of the dependency: the domain never depends on infrastructure. Infrastructure depends on the domain, by implementing its interfaces.
┌─────────────────────────┐
HTTP ──────▶ │ adapters │ ◀────── Kafka
│ (infrastructure) │
└───────────┬─────────────┘
│ implements
▼
┌─────────────────────────┐
│ ports (interfaces) │
│ domain │
└─────────────────────────┘
Concretely, on MissionMatch, a port looks like this:
// application/port/output/MatchResultRepository.kt
interface MatchResultRepository {
fun save(matchResult: MatchResult): MatchResult
fun findByFreelancerId(freelancerId: FreelancerId): List<MatchResult>
fun findByMissionIdAndFreelancerId(missionId: MissionId, freelancerId: FreelancerId): MatchResult?
}
The concrete adapter, in infrastructure/, implements this interface with
Spring Data JPA, with the domain never knowing anything about it:
// infrastructure/adapter/output/persistence/MatchResultRepositoryAdapter.kt
@Component
class MatchResultRepositoryAdapter(
private val jpaRepository: MatchResultJpaRepository,
) : MatchResultRepository {
override fun save(matchResult: MatchResult): MatchResult =
jpaRepository.save(MatchResultEntity.fromDomain(matchResult)).toDomain()
// ...
}
The use case that depends on MatchResultRepository has no idea Spring
Data JPA even exists. In tests, that same interface is simply mocked (with
Mockito) rather than swapped for a real database: that's exactly the
boundary where the mockist approach discussed in
the article on real TDD takes over
from the classicist approach used inside the domain.
What it changes in practice
A business use case written without a framework dependency can be tested in a few milliseconds, without a database or an HTTP server. This makes it possible to write tests before the implementation (TDD) and keep them fast throughout the project's lifetime, even once the test suite reaches several hundred cases.
It also changes how the system evolves. Swapping PostgreSQL for something else, or wiring in a new notification channel, becomes an isolated adapter change that never touches the business logic. The regression risk on a technical change becomes proportionate to the actual size of that change again.
The most common trap
The classic mistake is believing hexagonal architecture mandates a fixed
number of layers or a specific folder naming scheme. It isn't a question of
file structure: it's a question of dependency direction. You can very well
have a domain/ folder that quietly imports a type from an ORM library, and
end up with a facade hexagon, coupled to infrastructure in practice.
The question to ask in code review isn't "is this in the right folder?" but "if I delete every adapter, does the domain still compile on its own?"
The four layers, and what they're allowed to know
| Layer | Contains | Allowed to depend on |
|---|---|---|
domain/ | Business entities, invariant rules, ports (interfaces) | Nothing external, no framework |
application/ | Use cases, orchestration of ports | domain/ only |
infrastructure/ | Concrete adapters (database, Kafka, external APIs) | domain/ and application/, never the reverse |
presentation/ | HTTP controllers, UI components | application/, never infrastructure/ directly |
That table reads in one direction only: an arrow going back up from
infrastructure/ to domain/ (the domain importing a type from an
adapter) is the exact signal of a facade hexagon, whatever names the
folders are given.
An example
On MissionMatch, every use case lives in application/, depends only on
interfaces defined in domain/, and concrete adapters (database, Kafka,
HTTP) are wired in infrastructure/. The domain knows nothing about Spring
Boot, Kafka, or Postgres: it could be extracted as-is into another project
without modification.
The same principle structures kotlin-counterparty-risk, where counterparty risk calculations (EAD, PFE, CVA) are fully testable without depending on any external calculation engine: the mathematical complexity is isolated from the rest of the system, exactly like the rest of the business domain.
What I take from it
Hexagonal architecture isn't a goal in itself. It's a way of keeping the option to change your mind late in a project, without that change of mind costing a rewrite. The day that flexibility is no longer needed, the rigor it imposes does become an unnecessary cost. But on any project meant to live longer than a few months, that cost pays for itself as soon as the first unforeseen technical change comes along.