Design applications with Ports and Adapters for testability, flexibility, and independence from external systems. Covers domain isolation, port interfaces, adapter implementations, dependency inversion, and the patterns that make applications resilient to infrastructure changes.
Hexagonal Architecture, also known as Ports and Adapters, is a software design pattern that structures applications around their business logic and external interfaces. It becomes essential when you need to decouple business rules from external systems like databases, web frameworks, or message queues while maintaining testability and flexibility.
The central concept involves separating your application's business logic into a core domain that knows nothing about external systems. This domain defines interfaces (ports) that other components can implement (adapters).
// Example interface for user creation in core domain
public interface UserRepository {
User save(User user);
Optional<User> findById(String id);
}
The key is to define your business interfaces in the core module, not in external modules. If you're using a framework like Spring Boot, do not let your repository interfaces live in the web layer or configuration modules.
Create adapters that implement the ports defined in your core domain. These typically handle database persistence, HTTP requests, or external API calls.
// Adapter implementing UserRepository interface
@Component
public class JpaUserRepository implements UserRepository {
private final UserJpaRepository userJpaRepository;
public JpaUserRepository(UserJpaRepository userJpaRepository) {
this.userJpaRepository = userJpaRepository;
}
@Override
public User save(User user) {
return userJpaRepository.save(user);
}
@Override
public Optional<User> findById(String id) {
return userJpaRepository.findById(id);
}
}
The adapter should never know about the business logic in your core domain. If you see a class that implements both business logic and persistence, it's likely not following hexagonal architecture.
Use dependency injection to wire up the ports and adapters. The core domain should be injected with implementations of its interfaces, not concrete classes from external systems.
// Service using UserRepository interface (not implementation)
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(String name) {
User user = new User(name);
return userRepository.save(user);
}
}
For testing, create lightweight implementations of your ports that don't depend on external systems.
// Test adapter for UserRepository
public class InMemoryUserRepository implements UserRepository {
private final Map<String, User> users = new HashMap<>();
@Override
public User save(User user) {
users.put(user.getId(), user);
return user;
}
@Override
public Optional<User> findById(String id) {
return Optional.ofNullable(users.get(id));
}
}
Interfaces in your core domain should be stable. Changes to external systems should not force changes to business logic.
// This interface remains stable even if database schema changes
public interface UserRepository {
User save(User user);
Optional<User> findById(String id);
}
// Adapter adapts to external changes without affecting core
public class JpaUserRepository implements UserRepository {
// Database-specific code here, but core interface unchanged
}
External systems often require configuration or cross-cutting concerns like logging, monitoring, or security. These should be handled by adapters rather than business logic.
@Component
public class LoggingUserRepository implements UserRepository {
private final UserRepository delegate;
private final Logger logger = LoggerFactory.getLogger(LoggingUserRepository.class);
public LoggingUserRepository(UserRepository delegate) {
this.delegate = delegate;
}
@Override
public User save(User user) {
logger.info("Saving user: {}", user.getName());
User result = delegate.save(user);
logger.info("User saved with id: {}", result.getId());
return result;
}
@Override
public Optional<User> findById(String id) {
logger.debug("Finding user by id: {}", id);
return delegate.findById(id);
}
}
The most frequent error is creating dependencies from core domain back to external systems. Never let business logic classes directly reference database drivers, HTTP clients, or web frameworks.
// Wrong - Business logic depends on external system
public class UserService {
private final EntityManager entityManager; // Direct dependency
public UserService(EntityManager entityManager) {
this.entityManager = entityManager;
}
public User createUser(String name) {
User user = new User(name);
entityManager.persist(user); // Business logic knows about persistence
return user;
}
}
// Right - Core domain depends only on interfaces
public class UserService {
private final UserRepository userRepository; // Interface dependency
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User createUser(String name) {
User user = new User(name);
return userRepository.save(user); // Business logic uses interface
}
}
Your core business logic should be completely testable without external dependencies.
@Test
public void testCreateUser() {
UserRepository mockRepository = mock(UserRepository.class);
UserService userService = new UserService(mockRepository);
User user = new User("John");
when(mockRepository.save(user)).thenReturn(user);
User result = userService.createUser("John");
assertEquals("John", result.getName());
verify(mockRepository).save(user);
}
Use adapters for integration tests that involve external systems.
@Test
public void testCreateUserIntegration() {
// Setup real database adapter
UserRepository repository = new JpaUserRepository(entityManager);
UserService userService = new UserService(repository);
User result = userService.createUser("Jane");
assertNotNull(result.getId());
assertEquals("Jane", result.getName());
}
Configuration should be handled by adapters, not core business logic.
// Adapter configuration
@Configuration
public class RepositoryConfig {
@Bean
public UserRepository userRepository(JpaUserRepository jpaRepo) {
return new LoggingUserRepository(jpaRepo);
}
}
External adapters should handle system-specific errors while preserving business logic integrity.
@Component
public class RetryableUserRepository implements UserRepository {
private final UserRepository delegate;
public RetryableUserRepository(UserRepository delegate) {
this.delegate = delegate;
}
@Override
public User save(User user) {
// Handle retry logic for external systems
for (int i = 0; i < 3; i++) {
try {
return delegate.save(user);
} catch (DataAccessException e) {
if (i == 2) throw new RuntimeException("Failed after retries", e);
Thread.sleep(1000);
}
}
return delegate.save(user);
}
@Override
public Optional<User> findById(String id) {
return delegate.findById(id);
}
}
In production, ensure that adapters are correctly wired up through dependency injection containers.
# Example configuration for adapter selection
spring:
profiles:
active: prod
---
spring:
profiles: dev
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
---
spring:
profiles: prod
datasource:
url: jdbc:postgresql://db.example.com:5432/myapp
driver-class-name: org.postgresql.Driver
The most silent failure occurs when business logic accidentally imports external system classes. This breaks the hexagonal boundary and makes testing harder.
// Silent failure - Core domain imports database-specific class
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// This method should not exist in core domain
public void saveUserWithTransaction(User user) {
// Direct usage of external transaction system
TransactionTemplate tx = new TransactionTemplate(transactionManager);
tx.execute(status -> {
userRepository.save(user);
return null;
});
}
}
Another failure is having multiple adapters for the same port, which can cause confusion during dependency resolution.
When evolving your ports, maintain backward compatibility. Add methods to interfaces rather than changing existing ones.
// Evolution of interface
public interface UserRepository {
User save(User user);
Optional<User> findById(String id);
// New method without breaking existing implementations
List<User> findByEmail(String email);
}
Adapters can introduce overhead. Monitor the performance impact of logging, retry mechanisms, or additional layers.
// Consider performance implications of adapter layers
public class CachingUserRepository implements UserRepository {
private final UserRepository delegate;
private final Cache<String, User> cache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
// Implementation that may add latency
}
Adapters are the right place to add monitoring instrumentation.
@Component
public class MetricsUserRepository implements UserRepository {
private final UserRepository delegate;
private final MeterRegistry meterRegistry;
public MetricsUserRepository(UserRepository delegate, MeterRegistry meterRegistry) {
this.delegate = delegate;
this.meterRegistry = meterRegistry;
}
@Override
public User save(User user) {
Timer.Sample sample = Timer.start(meterRegistry);
try {
return delegate.save(user);
} finally {
sample.stop(Timer.builder("user.save.duration")
.tag("operation", "save")
.register(meterRegistry));
}
}
@Override
public Optional<User> findById(String id) {
return delegate.findById(id);
}
}
When migrating existing code, extract business logic into core interfaces first, then implement adapters for current systems.
// Migration example - Extract interface first
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(String id);
}
// Then create adapter for existing implementation
public class LegacyOrderRepository implements OrderRepository {
private final OldOrderPersistenceService service;
public LegacyOrderRepository(OldOrderPersistenceService service) {
this.service = service;
}
@Override
public Order save(Order order) {
return service.save(order);
}
@Override
public Optional<Order> findById(String id) {
return service.findById(id);
}
}
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy