Skip to main content

Command Palette

Search for a command to run...

Implementing Hexagonal Architecture in Spring Boot: A Comprehensive Guide

Published
β€’9 min read
S

πŸ‘‹ 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

  1. Keep Domain Logic Pure: The domain layer should have no dependencies on frameworks or external libraries.

  2. Use Meaningful Names: Ports and adapters should have descriptive names that reflect their purpose.

  3. Dependency Rule: Dependencies should point inward. The domain should not depend on adapters.

  4. Single Responsibility: Each adapter should have a single, well-defined responsibility.

  5. Interface Segregation: Create focused, specific ports rather than large, general-purpose ones.

  6. 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.