12.1 Hexagonal Architecture: Protecting the Business Core with Ports
Databases, HTTP, and message queues are not the business itself. In hexagonal architecture, ports express what the core can do and what it needs, then adapters handle the specific protocols, directing external technology dependencies toward the core.
"The hexagon" is just a schematic illustration; the number of sides has no meaning. What matters is that there exists a testable boundary between the application's internal logic and the external world.
Two Types of Ports
Ports are typically divided into two categories:
- Driving ports (inbound / primary): use cases that external entities request the system to perform;
- Driven ports (outbound / secondary): capabilities that a use case needs in order to complete its work.
HTTP Controller ─┐
CLI Command ─────┼─> [Driving port | Application & Domain | Driven port] ─> PostgreSQL
Message Handler ─┘ ├─> Kafka
└─> Payment APIArrows show runtime request flow, not source dependencies. For example, a JDBC adapter implements the core repository interface, so its source dependency still points inward. A port need not be a Java interface; a defined message protocol or function signature can serve the same role.
Ports are named in business language. ForRegisteringEntry is more expressive of an entry point than EntryService; TournamentRepository better conveys the core capability a system needs to perform its work than DatabaseClient.
Core Defines the Contract First
These Java 21 project fragments assume domain commands, IDs, tournaments, and events; save each public type separately. This command uses TournamentId, unlike the UUID variant in the previous lesson. The driving-port contract requires authenticated, authorized, validated input. A message adapter must meet that contract too.
public interface ForRegisteringEntry {
RegistrationResult register(RegisterEntryCommand command);
}
public interface TournamentRepository {
java.util.Optional<Tournament> findById(TournamentId id);
void save(Tournament tournament);
}
public interface EventOutbox {
void append(DomainEvent event);
}The application service implements the driving port and depends on the driven port:
public final class RegisterEntryService implements ForRegisteringEntry {
private final TournamentRepository tournaments;
private final EventOutbox outbox;
public RegisterEntryService(TournamentRepository tournaments, EventOutbox outbox) {
this.tournaments = java.util.Objects.requireNonNull(tournaments);
this.outbox = java.util.Objects.requireNonNull(outbox);
}
@Override
public RegistrationResult register(RegisterEntryCommand command) {
Tournament tournament = tournaments.findById(command.tournamentId())
.orElseThrow(TournamentNotFound::new);
DomainEvent event = tournament.register(command.playerId());
tournaments.save(tournament);
outbox.append(event);
return RegistrationResult.accepted(tournament.remainingSlots());
}
}The core method itself provides no transaction guarantee. If save commits and append then fails, independently committing adapters leave a registration without its event. The composition root below adds an outer transaction decorator for same-database atomicity; call order alone has not supplied that guarantee.
The core does not import Spring, JPA, Kafka, or HTTP-related libraries. Whether annotations are used is not the sole determining factor, but if the core must start a framework in order to run a business test, the boundary has typically become blurred.
Adapters Handle Translation
Driving adapters translate external requests into port inputs and translate the results back into the original protocol:
import java.util.UUID;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
final class EntryHttpAdapter {
private final ForRegisteringEntry registerEntry;
EntryHttpAdapter(ForRegisteringEntry registerEntry) {
this.registerEntry = java.util.Objects.requireNonNull(registerEntry);
}
@PostMapping("/tournaments/{id}/entries")
ResponseEntity<EntryResponse> register(
@PathVariable("id") UUID id,
@Valid @RequestBody EntryRequest request
) {
var result = registerEntry.register(
new RegisterEntryCommand(new TournamentId(id), request.playerId())
);
return ResponseEntity.ok(EntryResponse.from(result));
}
}The HTTP fragment assumes constrained EntryRequest fields, EntryResponse mapping, authentication/authorization filters or decorators, and exception handlers, with Spring MVC and Bean Validation configured. @Valid cannot check undeclared constraints or replace object-level authorization.
Driven adapters translate core contracts into technical operations. This Spring JDBC 6.2 fragment is only the query method inside a complete project-defined JdbcTournamentRepository. That class still needs constructor-injected JdbcClient and a save implementation:
@Override
public java.util.Optional<Tournament> findById(TournamentId id) {
return jdbc.sql("select id, capacity, status from tournament where id = :id")
.param("id", id.value())
.query(TournamentRow.class)
.optional()
.map(TournamentMapper::toDomain);
}The columns illustrate mapping; the project supplies TournamentRow and its mapper. If an aggregate needs registered players, remaining capacity, or a version, load those too. A three-column partial object cannot enforce every tournament invariant. optional() returns empty for zero rows and fails on multiple rows; it does not pick the first.
Mapping keeps table structures, vendor fields, and framework proxies outside the core. It must also translate database exceptions into the failures promised by the port.
The Composition Root Assembles the System
The transaction decorator lives outside the core and uses TransactionTemplate directly; it does not need an annotation proxy to start a transaction:
public final class TransactionalRegistration implements ForRegisteringEntry {
private final ForRegisteringEntry target;
private final org.springframework.transaction.support.TransactionTemplate tx;
public TransactionalRegistration(ForRegisteringEntry target,
org.springframework.transaction.support.TransactionTemplate tx) {
this.target = java.util.Objects.requireNonNull(target);
this.tx = java.util.Objects.requireNonNull(tx);
}
@Override
public RegistrationResult register(RegisterEntryCommand command) {
return tx.execute(status -> java.util.Objects.requireNonNull(
target.register(command), "result"));
}
}This assembly fragment assumes a configured dataSource, complete JDBC adapters, and their schemas:
var jdbc = org.springframework.jdbc.core.simple.JdbcClient.create(dataSource);
TournamentRepository repository = new JdbcTournamentRepository(jdbc);
EventOutbox outbox = new JdbcEventOutbox(jdbc);
ForRegisteringEntry core = new RegisterEntryService(repository, outbox);
var tx = new org.springframework.transaction.support.TransactionTemplate(
new org.springframework.jdbc.support.JdbcTransactionManager(dataSource));
ForRegisteringEntry useCase = new TransactionalRegistration(core, tx);Both repository and Outbox must use Spring-aware connection access for the same data source. A shared variable name does not prevent an adapter from opening a separate connection or transaction or committing early. Synchronous register() completion commits; a runtime exception rolls back. Multiple data sources and asynchronous thread switches are outside this local transaction guarantee. An integration test should force Outbox writing to fail and verify that registration is also absent.
The core knows interfaces, while the composition root knows concrete implementations. Relying on a service locator within business code for "purity" actually hides dependencies.
Port Contracts Also Need Testing
Replacing an adapter does not mean the adapter is inherently equivalent. A repository port must define:
- What to return when an object is not found;
- How to represent concurrent version conflicts;
- How the ID and version change after a save operation;
- Whether queries include deleted objects;
- Whether transactions are controlled by the caller or by the adapter itself.
The same contract tests can be run against both in-memory adapters and real database adapters. If the in-memory implementation fails to simulate critical semantics (such as unique constraints or version conflicts) the core tests may generate false confidence.
Don't Make Every Object a Port
Ports should correspond to stable system boundaries or business capabilities. Creating interfaces for things like logging, clocks, or random number generation can improve testability; however, mechanically adding an interface to every class only increases the cost of navigation and naming.
Before adopting a hexagonal architecture, ask yourself:
- Will this external dependency significantly slow down or block core testing?
- Is its protocol or vendor likely to change?
- Does the core need to define its own failure semantics?
- Can a translation layer prevent external models from polluting the business model?
The next lesson compares hexagonal architecture with onion and Clean Architecture, and explores how to ensure dependency rules aren't just drawn in diagrams.
References
Alistair Cockburn, Hexagonal Architecture
Martin Fowler, Dependency Inversion Principle