Hexagonal Architecture

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.

Define Your Core Domain

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.

Implement External Adapters

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.

Handle Dependency Injection

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);
    }
}

Configure Test Adapters

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));
    }
}

Manage Interface Evolution

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
}

Handle Cross-Cutting Concerns

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);
    }
}

Avoid Common Pitfalls

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
    }
}

Test Isolation

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);
}

Integration Testing

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 Management

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);
    }
}

Error Handling Strategy

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);
    }
}

Deployment Considerations

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

Common Failure Modes

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.

Versioning Strategy

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);
}

Performance Considerations

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
}

Monitoring and Observability

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);
    }
}

Migration from Existing Architecture

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.