Implementing Hexagonal Architecture in Spring Boot: A Comprehensive Guide
π Hey there! Iβm Shohanur Rahman!
Iβm a backend developer with over 5.5 years of experience in building scalable and efficient web applications. My work focuses on Java, Spring Boot, and microservices architecture, where I love designing robust API solutions and creating secure middleware for complex integrations.
πΌ What I Do Backend Development: Expert in Spring Boot, Spring Cloud, and Spring WebFlux, I create high-performance microservices that drive seamless user experiences. Cloud & DevOps: AWS enthusiast, skilled in using EC2, S3, RDS, and Docker to design scalable and reliable cloud infrastructures. Digital Security: Passionate about securing applications with OAuth2, Keycloak, and digital signatures for data integrity and privacy. π Current Projects Iβm currently working on API integrations with Spring Cloud Gateway and designing an e-invoicing middleware. My projects often involve asynchronous processing, digital signature implementations, and ensuring high standards of security.
π Why I Write I enjoy sharing what Iβve learned through blog posts, covering everything from backend design to API security and cloud best practices. Check out my posts if youβre into backend dev, cloud tech, or digital security!
Introduction
Hexagonal Architecture, also known as Ports and Adapters architecture, is a powerful software design pattern that promotes loose coupling, testability, and maintainability. In this comprehensive guide, we'll explore how to implement Hexagonal Architecture in Spring Boot applications, enabling you to build robust, scalable systems that are easy to test and modify.
What is Hexagonal Architecture?
Hexagonal Architecture, introduced by Alistair Cockburn, is an architectural pattern that aims to create loosely coupled application components that can be easily connected to their software environment through ports and adapters. The key idea is to isolate the core business logic from external concerns like databases, APIs, and user interfaces.
Core Concepts
The Hexagon (Core Domain): The center of your application containing business logic, domain models, and use cases. This layer should be completely independent of external frameworks and technologies.
Ports: Interfaces that define how the outside world can interact with your application (inbound ports) and how your application interacts with external systems (outbound ports).
Adapters: Concrete implementations of ports that handle the translation between the external world and your domain logic.
Benefits of Hexagonal Architecture
- Framework Independence: Your business logic isn't tied to Spring Boot or any other framework
- Testability: Easy to write unit tests without external dependencies
- Flexibility: Swap implementations without touching core business logic
- Maintainability: Clear separation of concerns makes code easier to understand and modify
- Technology Agnostic: Easy to migrate to different databases, frameworks, or technologies
Project Structure
Let's organize our Spring Boot project following hexagonal principles:
src/main/java/com/example/application/
βββ domain/ # Core business logic
β βββ model/ # Domain entities
β β βββ User.java
β βββ port/
β β βββ in/ # Inbound ports (use cases)
β β β βββ CreateUserUseCase.java
β β βββ out/ # Outbound ports
β β βββ UserRepositoryPort.java
β βββ service/ # Domain services
β βββ UserService.java
βββ application/ # Application layer
β βββ service/ # Use case implementations
β βββ UserApplicationService.java
βββ adapter/ # Adapters
βββ in/ # Inbound adapters
β βββ web/ # REST controllers
β βββ UserController.java
βββ out/ # Outbound adapters
βββ persistence/ # Database adapters
βββ UserJpaEntity.java
βββ UserJpaRepository.java
βββ UserRepositoryAdapter.java
Step-by-Step Implementation
Step 1: Define Domain Model
The domain model represents your core business entities, free from any framework dependencies.
package com.example.application.domain.model;
public class User {
private Long id;
private String username;
private String email;
private String fullName;
// Constructor
public User(Long id, String username, String email, String fullName) {
this.id = id;
this.username = username;
this.email = email;
this.fullName = fullName;
}
// Factory method for creating new users
public static User createNewUser(String username, String email, String fullName) {
validateUsername(username);
validateEmail(email);
return new User(null, username, email, fullName);
}
// Business logic validation
private static void validateUsername(String username) {
if (username == null || username.trim().isEmpty()) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (username.length() < 3) {
throw new IllegalArgumentException("Username must be at least 3 characters");
}
}
private static void validateEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Invalid email format");
}
}
// Getters
public Long getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public String getFullName() { return fullName; }
// Setter for id (used by repository)
public void setId(Long id) { this.id = id; }
}
Step 2: Define Inbound Port (Use Case)
Inbound ports define what your application can do - these are your use cases.
package com.example.application.domain.port.in;
import com.example.application.domain.model.User;
public interface CreateUserUseCase {
User createUser(CreateUserCommand command);
}
package com.example.application.domain.port.in;
public class CreateUserCommand {
private final String username;
private final String email;
private final String fullName;
public CreateUserCommand(String username, String email, String fullName) {
this.username = username;
this.email = email;
this.fullName = fullName;
}
public String getUsername() { return username; }
public String getEmail() { return email; }
public String getFullName() { return fullName; }
}
Step 3: Define Outbound Port
Outbound ports define how your application interacts with external systems.
package com.example.application.domain.port.out;
import com.example.application.domain.model.User;
import java.util.Optional;
public interface UserRepositoryPort {
User save(User user);
Optional<User> findById(Long id);
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
Step 4: Implement Use Case (Domain Service)
The domain service contains your business logic and orchestrates the use case.
package com.example.application.application.service;
import com.example.application.domain.model.User;
import com.example.application.domain.port.in.CreateUserCommand;
import com.example.application.domain.port.in.CreateUserUseCase;
import com.example.application.domain.port.out.UserRepositoryPort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional
public class UserApplicationService implements CreateUserUseCase {
private final UserRepositoryPort userRepositoryPort;
public UserApplicationService(UserRepositoryPort userRepositoryPort) {
this.userRepositoryPort = userRepositoryPort;
}
@Override
public User createUser(CreateUserCommand command) {
// Business rules validation
if (userRepositoryPort.existsByUsername(command.getUsername())) {
throw new IllegalStateException("Username already exists");
}
if (userRepositoryPort.existsByEmail(command.getEmail())) {
throw new IllegalStateException("Email already exists");
}
// Create domain entity
User user = User.createNewUser(
command.getUsername(),
command.getEmail(),
command.getFullName()
);
// Persist through port
return userRepositoryPort.save(user);
}
}
Step 5: Implement Outbound Adapter (Persistence)
The outbound adapter translates between your domain model and the external system (database).
package com.example.application.adapter.out.persistence;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(name = "full_name")
private String fullName;
// Constructors, getters, and setters
public UserJpaEntity() {}
public UserJpaEntity(Long id, String username, String email, String fullName) {
this.id = id;
this.username = username;
this.email = email;
this.fullName = fullName;
}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getFullName() { return fullName; }
public void setFullName(String fullName) { this.fullName = fullName; }
}
package com.example.application.adapter.out.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, Long> {
Optional<UserJpaEntity> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
package com.example.application.adapter.out.persistence;
import com.example.application.domain.model.User;
import com.example.application.domain.port.out.UserRepositoryPort;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class UserRepositoryAdapter implements UserRepositoryPort {
private final UserJpaRepository userJpaRepository;
public UserRepositoryAdapter(UserJpaRepository userJpaRepository) {
this.userJpaRepository = userJpaRepository;
}
@Override
public User save(User user) {
UserJpaEntity entity = toEntity(user);
UserJpaEntity savedEntity = userJpaRepository.save(entity);
return toDomain(savedEntity);
}
@Override
public Optional<User> findById(Long id) {
return userJpaRepository.findById(id)
.map(this::toDomain);
}
@Override
public Optional<User> findByUsername(String username) {
return userJpaRepository.findByUsername(username)
.map(this::toDomain);
}
@Override
public boolean existsByUsername(String username) {
return userJpaRepository.existsByUsername(username);
}
@Override
public boolean existsByEmail(String email) {
return userJpaRepository.existsByEmail(email);
}
// Mapping methods
private UserJpaEntity toEntity(User user) {
return new UserJpaEntity(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getFullName()
);
}
private User toDomain(UserJpaEntity entity) {
User user = new User(
entity.getId(),
entity.getUsername(),
entity.getEmail(),
entity.getFullName()
);
return user;
}
}
Step 6: Implement Inbound Adapter (REST Controller)
The inbound adapter handles external requests and delegates to use cases.
package com.example.application.adapter.in.web;
import com.example.application.domain.model.User;
import com.example.application.domain.port.in.CreateUserCommand;
import com.example.application.domain.port.in.CreateUserUseCase;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final CreateUserUseCase createUserUseCase;
public UserController(CreateUserUseCase createUserUseCase) {
this.createUserUseCase = createUserUseCase;
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
CreateUserCommand command = new CreateUserCommand(
request.getUsername(),
request.getEmail(),
request.getFullName()
);
User user = createUserUseCase.createUser(command);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(toResponse(user));
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getFullName()
);
}
}
package com.example.application.adapter.in.web;
public class CreateUserRequest {
private String username;
private String email;
private String fullName;
// Constructors, getters, and setters
public CreateUserRequest() {}
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getFullName() { return fullName; }
public void setFullName(String fullName) { this.fullName = fullName; }
}
package com.example.application.adapter.in.web;
public class UserResponse {
private Long id;
private String username;
private String email;
private String fullName;
public UserResponse(Long id, String username, String email, String fullName) {
this.id = id;
this.username = username;
this.email = email;
this.fullName = fullName;
}
// Getters
public Long getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public String getFullName() { return fullName; }
}
Testing Your Hexagonal Architecture
One of the main benefits of hexagonal architecture is improved testability.
Unit Testing Domain Logic
package com.example.application.domain.model;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserTest {
@Test
void shouldCreateValidUser() {
User user = User.createNewUser("johndoe", "john@example.com", "John Doe");
assertNotNull(user);
assertEquals("johndoe", user.getUsername());
assertEquals("john@example.com", user.getEmail());
assertEquals("John Doe", user.getFullName());
}
@Test
void shouldThrowExceptionForInvalidUsername() {
assertThrows(IllegalArgumentException.class, () -> {
User.createNewUser("ab", "john@example.com", "John Doe");
});
}
@Test
void shouldThrowExceptionForInvalidEmail() {
assertThrows(IllegalArgumentException.class, () -> {
User.createNewUser("johndoe", "invalid-email", "John Doe");
});
}
}
Testing Use Cases with Mock Adapters
package com.example.application.application.service;
import com.example.application.domain.model.User;
import com.example.application.domain.port.in.CreateUserCommand;
import com.example.application.domain.port.out.UserRepositoryPort;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserApplicationServiceTest {
@Mock
private UserRepositoryPort userRepositoryPort;
@InjectMocks
private UserApplicationService userApplicationService;
@Test
void shouldCreateUserSuccessfully() {
// Given
CreateUserCommand command = new CreateUserCommand(
"johndoe",
"john@example.com",
"John Doe"
);
when(userRepositoryPort.existsByUsername("johndoe")).thenReturn(false);
when(userRepositoryPort.existsByEmail("john@example.com")).thenReturn(false);
when(userRepositoryPort.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(1L);
return user;
});
// When
User createdUser = userApplicationService.createUser(command);
// Then
assertNotNull(createdUser);
assertEquals("johndoe", createdUser.getUsername());
verify(userRepositoryPort, times(1)).save(any(User.class));
}
@Test
void shouldThrowExceptionWhenUsernameExists() {
// Given
CreateUserCommand command = new CreateUserCommand(
"johndoe",
"john@example.com",
"John Doe"
);
when(userRepositoryPort.existsByUsername("johndoe")).thenReturn(true);
// When & Then
assertThrows(IllegalStateException.class, () -> {
userApplicationService.createUser(command);
});
verify(userRepositoryPort, never()).save(any(User.class));
}
}
Maven Dependencies
Add these dependencies to your pom.xml:
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Database (H2 for development) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Best Practices
Keep Domain Logic Pure: The domain layer should have no dependencies on frameworks or external libraries.
Use Meaningful Names: Ports and adapters should have descriptive names that reflect their purpose.
Dependency Rule: Dependencies should point inward. The domain should not depend on adapters.
Single Responsibility: Each adapter should have a single, well-defined responsibility.
Interface Segregation: Create focused, specific ports rather than large, general-purpose ones.
Test at Multiple Levels: Unit test domain logic, integration test adapters, and test use cases with mocked ports.
Conclusion
Hexagonal Architecture provides a robust foundation for building maintainable, testable Spring Boot applications. By separating business logic from technical concerns through ports and adapters, you create systems that are flexible, easier to test, and more resilient to change.
While the initial setup requires more structure than a traditional layered architecture, the long-term benefits in maintainability, testability, and flexibility make it an excellent choice for complex business applications.
Start implementing hexagonal architecture in your next Spring Boot project and experience the benefits of clean, decoupled code!
Additional Resources
- "Hexagonal Architecture" by Alistair Cockburn
- "Clean Architecture" by Robert C. Martin
- Spring Boot Documentation: https://spring.io/projects/spring-boot
- Domain-Driven Design principles
Happy coding! Feel free to adapt this architecture pattern to your specific needs and project requirements.