Hexagonal Architecture: Ports and Adapters
Most developers learn the traditional three-tier architecture early: presentation layer, business logic layer, data access layer. It seems clean. It works for tutorials. Then you inherit a...
Key Insights
- Hexagonal architecture isolates your business logic from frameworks, databases, and external services by defining explicit boundaries through ports (interfaces) and adapters (implementations)
- The “inside-out” dependency rule means your domain code never imports infrastructure concerns—adapters depend on ports, not the other way around
- This pattern shines in complex domains and long-lived applications, but adds overhead that may not justify itself in simple CRUD apps or short-term projects
The Problem with Layered Architecture
Most developers learn the traditional three-tier architecture early: presentation layer, business logic layer, data access layer. It seems clean. It works for tutorials. Then you inherit a five-year-old codebase and discover the nightmare.
Your “business logic” layer imports Hibernate annotations. Your controllers contain validation rules that belong in the domain. Your unit tests require a running PostgreSQL instance because someone thought it was fine to call the repository directly from the service constructor.
The fundamental problem with layered architecture isn’t the layers—it’s the direction of dependencies. When your business logic depends on your database layer, you’ve coupled your core value proposition to an implementation detail. Swapping PostgreSQL for MongoDB becomes a rewrite. Testing a pricing calculation requires spinning up infrastructure. Your domain model becomes a graveyard of ORM annotations.
Hexagonal architecture, also called ports and adapters, inverts these dependencies. Your business logic sits at the center, blissfully unaware of HTTP, SQL, or message queues. Everything else plugs into it.
Core Concepts: The Hexagon Explained
Picture a hexagon. Inside sits your application core: domain entities, business rules, use cases. This code has zero external dependencies. No Spring annotations. No database drivers. Just plain objects expressing your business domain.
The hexagon’s edges are ports—interfaces that define how the outside world interacts with your application. Ports express intent without implementation details. “I need to store a user” becomes a UserRepository interface. “Someone wants to register” becomes a RegisterUser use case.
Adapters sit outside the hexagon, implementing those ports. A PostgreSQL adapter implements UserRepository. A REST controller calls RegisterUser. The adapter knows about the messy real world; the port keeps that mess from leaking inward.
Here’s a domain entity with absolutely no infrastructure concerns:
public class User {
private final UserId id;
private final Email email;
private final HashedPassword password;
private Instant createdAt;
public User(UserId id, Email email, HashedPassword password) {
this.id = Objects.requireNonNull(id);
this.email = Objects.requireNonNull(email);
this.password = Objects.requireNonNull(password);
this.createdAt = Instant.now();
}
public boolean verifyPassword(String rawPassword, PasswordHasher hasher) {
return hasher.verify(rawPassword, this.password);
}
// Getters omitted for brevity
}
Notice what’s missing: no @Entity, no @Id, no framework. This class compiles without a single external dependency. The PasswordHasher is another port—an interface your domain defines, not a concrete BCrypt implementation.
Ports: Defining Your Application Boundaries
Ports come in two flavors, and understanding the distinction matters.
Driving ports (primary/inbound) define how external actors trigger your application. A REST API calls a driving port. A CLI command calls a driving port. A scheduled job calls a driving port. These are your use cases—the actions your application performs.
Driven ports (secondary/outbound) define what your application needs from the outside world. Database access, email sending, payment processing—these are driven ports. Your application core calls them; adapters implement them.
Here’s a driving port for user registration:
public interface RegisterUser {
RegistrationResult execute(RegisterUserCommand command);
}
public record RegisterUserCommand(String email, String password) {}
public sealed interface RegistrationResult
permits RegistrationResult.Success, RegistrationResult.EmailAlreadyExists {
record Success(UserId userId) implements RegistrationResult {}
record EmailAlreadyExists() implements RegistrationResult {}
}
And the driven ports it needs:
public interface UserRepository {
Optional<User> findByEmail(Email email);
void save(User user);
}
public interface PasswordHasher {
HashedPassword hash(String rawPassword);
boolean verify(String rawPassword, HashedPassword hashed);
}
public interface IdGenerator {
UserId generateUserId();
}
The use case implementation lives in the application core, depending only on these interfaces:
public class RegisterUserUseCase implements RegisterUser {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
private final IdGenerator idGenerator;
public RegisterUserUseCase(UserRepository userRepository,
PasswordHasher passwordHasher,
IdGenerator idGenerator) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.idGenerator = idGenerator;
}
@Override
public RegistrationResult execute(RegisterUserCommand command) {
Email email = Email.parse(command.email());
if (userRepository.findByEmail(email).isPresent()) {
return new RegistrationResult.EmailAlreadyExists();
}
HashedPassword hashedPassword = passwordHasher.hash(command.password());
UserId userId = idGenerator.generateUserId();
User user = new User(userId, email, hashedPassword);
userRepository.save(user);
return new RegistrationResult.Success(userId);
}
}
Adapters: Plugging Into the Real World
Adapters translate between your clean domain ports and the messy reality of databases, HTTP, and third-party APIs. Each adapter knows exactly one thing about the outside world.
Here’s a PostgreSQL adapter for UserRepository:
public class PostgresUserRepository implements UserRepository {
private final JdbcTemplate jdbc;
public PostgresUserRepository(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
}
@Override
public Optional<User> findByEmail(Email email) {
String sql = "SELECT id, email, password_hash, created_at FROM users WHERE email = ?";
List<User> users = jdbc.query(sql, this::mapRow, email.value());
return users.stream().findFirst();
}
@Override
public void save(User user) {
String sql = "INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)";
jdbc.update(sql,
user.getId().value(),
user.getEmail().value(),
user.getPassword().value(),
Timestamp.from(user.getCreatedAt()));
}
private User mapRow(ResultSet rs, int rowNum) throws SQLException {
return new User(
new UserId(UUID.fromString(rs.getString("id"))),
Email.parse(rs.getString("email")),
new HashedPassword(rs.getString("password_hash"))
);
}
}
And an in-memory adapter for testing:
public class InMemoryUserRepository implements UserRepository {
private final Map<Email, User> users = new ConcurrentHashMap<>();
@Override
public Optional<User> findByEmail(Email email) {
return Optional.ofNullable(users.get(email));
}
@Override
public void save(User user) {
users.put(user.getEmail(), user);
}
public void clear() {
users.clear();
}
}
Same interface, completely different implementations. Your use case doesn’t know or care which one it’s using.
Dependency Injection: Wiring It Together
The composition root—typically your application’s entry point or configuration class—wires adapters to ports. This is the only place that knows about concrete implementations.
@Configuration
public class ApplicationConfig {
@Bean
public UserRepository userRepository(DataSource dataSource) {
return new PostgresUserRepository(dataSource);
}
@Bean
public PasswordHasher passwordHasher() {
return new BCryptPasswordHasher();
}
@Bean
public IdGenerator idGenerator() {
return new UUIDIdGenerator();
}
@Bean
public RegisterUser registerUser(UserRepository userRepository,
PasswordHasher passwordHasher,
IdGenerator idGenerator) {
return new RegisterUserUseCase(userRepository, passwordHasher, idGenerator);
}
}
For tests, you wire differently:
public class TestConfig {
public static RegisterUser createTestableRegistration() {
return new RegisterUserUseCase(
new InMemoryUserRepository(),
new PlainTextPasswordHasher(), // Faster for tests
new SequentialIdGenerator() // Predictable for assertions
);
}
}
Testing Benefits
This is where hexagonal architecture pays dividends. Your use case tests become trivial:
class RegisterUserUseCaseTest {
private InMemoryUserRepository userRepository;
private RegisterUser registerUser;
@BeforeEach
void setUp() {
userRepository = new InMemoryUserRepository();
registerUser = new RegisterUserUseCase(
userRepository,
new PlainTextPasswordHasher(),
new SequentialIdGenerator()
);
}
@Test
void registersNewUser() {
var command = new RegisterUserCommand("test@example.com", "password123");
var result = registerUser.execute(command);
assertInstanceOf(RegistrationResult.Success.class, result);
assertTrue(userRepository.findByEmail(Email.parse("test@example.com")).isPresent());
}
@Test
void rejectsDuplicateEmail() {
var command = new RegisterUserCommand("test@example.com", "password123");
registerUser.execute(command);
var result = registerUser.execute(command);
assertInstanceOf(RegistrationResult.EmailAlreadyExists.class, result);
}
}
No mocking frameworks. No database containers. Tests run in milliseconds. When you do need integration tests, swap in real adapters:
@SpringBootTest
class UserRegistrationIntegrationTest {
@Autowired
private RegisterUser registerUser;
@Autowired
private UserRepository userRepository; // Real PostgreSQL
@Test
void persistsUserToDatabase() {
var command = new RegisterUserCommand("integration@test.com", "password");
var result = registerUser.execute(command);
assertInstanceOf(RegistrationResult.Success.class, result);
// Verify actual database state
}
}
When to Use (and When Not To)
Hexagonal architecture isn’t free. You’re writing more interfaces, more classes, more wiring code. For a weekend project or a simple CRUD API, this overhead doesn’t pay off.
Use hexagonal architecture when:
- Your domain logic is complex and valuable
- You expect the application to live for years
- You need to swap infrastructure (database migrations, cloud provider changes)
- Multiple teams work on different parts of the system
- You want fast, reliable unit tests
Skip it when:
- You’re building a prototype or MVP
- The application is mostly data shuffling with little business logic
- Your team is unfamiliar with the pattern and deadlines are tight
- The project has a defined end date within months
The pattern scales with complexity. Start with the core concepts—isolate your domain, define ports for external dependencies—and add formality as needed. You don’t need perfect hexagons on day one. You need dependencies pointing inward and business logic that doesn’t know about Spring.