As your Spring Boot applications grow in complexity, so does the importance of maintainable, scalable, and testable architecture. Enter Clean Architecture and Hexagonal Design (Ports & Adapters) β two powerful paradigms that help you decouple business logic from infrastructure and delivery layers.
This guide walks you through implementing Spring Boot Clean Architecture Hexagonal Design, using real code examples and structure to build robust and long-lasting systems.

π§± What is Clean Architecture?
Introduced by Robert C. Martin (Uncle Bob), Clean Architecture ensures:
- Business logic is independent of frameworks
- Dependencies point inward toward core use cases
- Itβs testable, scalable, and easy to change
πΆ What is Hexagonal Architecture?
Also known as Ports & Adapters, this architectural style has three main layers:
- Domain (Core) β Business rules and models
- Application (Use Cases) β Application logic
- Adapters (Inbound/Outbound) β Frameworks, databases, REST, messaging
ποΈ Project Structure
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
com.kscodes.springboot.advanced β βββ domain β Core business logic (no Spring dependencies) β βββ model β βββ port β βββ application β Use cases / services β βββ service β βββ infrastructure β Adapters (DB, REST, Messaging, etc.) β βββ repository β βββ controller β βββ config β Spring Configuration |
π¦ Maven Dependencies
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency> </dependencies> |
π§ 1. Domain Layer (Pure Java)
Product.java
(Domain Entity)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
package com.kscodes.springboot.advanced.domain.model; public class Product { private Long id; private String name; public Product(Long id, String name) { this.id = id; this.name = name; } // Getters } |
ProductRepositoryPort.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
package com.kscodes.springboot.advanced.domain.port; import com.kscodes.springboot.advanced.domain.model.Product; import java.util.List; import java.util.Optional; public interface ProductRepositoryPort { List<Product> findAll(); Optional<Product> findById(Long id); Product save(Product product); } |
π§° 2. Application Layer (Use Cases)
ProductService.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
package com.kscodes.springboot.advanced.application.service; import com.kscodes.springboot.advanced.domain.model.Product; import com.kscodes.springboot.advanced.domain.port.ProductRepositoryPort; import java.util.List; import java.util.Optional; public class ProductService { private final ProductRepositoryPort repository; public ProductService(ProductRepositoryPort repository) { this.repository = repository; } public List<Product> getAllProducts() { return repository.findAll(); } public Optional<Product> getProductById(Long id) { return repository.findById(id); } public Product createProduct(Product product) { return repository.save(product); } } |
π§± 3. Infrastructure Layer
JpaProductEntity.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
package com.kscodes.springboot.advanced.infrastructure.repository; import jakarta.persistence.*; @Entity @Table(name = "products") public class JpaProductEntity { @Id @GeneratedValue private Long id; private String name; // Getters and setters } |
SpringDataProductRepository.java
1 2 3 4 5 6 7 8 |
package com.kscodes.springboot.advanced.infrastructure.repository; import org.springframework.data.jpa.repository.JpaRepository; public interface SpringDataProductRepository extends JpaRepository<JpaProductEntity, Long> {} |
JpaProductAdapter.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package com.kscodes.springboot.advanced.infrastructure.repository; import com.kscodes.springboot.advanced.domain.model.Product; import com.kscodes.springboot.advanced.domain.port.ProductRepositoryPort; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @Repository public class JpaProductAdapter implements ProductRepositoryPort { private final SpringDataProductRepository repo; public JpaProductAdapter(SpringDataProductRepository repo) { this.repo = repo; } @Override public List<Product> findAll() { return repo.findAll().stream() .map(e -> new Product(e.getId(), e.getName())) .collect(Collectors.toList()); } @Override public Optional<Product> findById(Long id) { return repo.findById(id).map(e -> new Product(e.getId(), e.getName())); } @Override public Product save(Product product) { JpaProductEntity entity = new JpaProductEntity(); entity.setName(product.getName()); JpaProductEntity saved = repo.save(entity); return new Product(saved.getId(), saved.getName()); } } |
π 4. REST Controller (Inbound Adapter)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
package com.kscodes.springboot.advanced.infrastructure.controller; import com.kscodes.springboot.advanced.application.service.ProductService; import com.kscodes.springboot.advanced.domain.model.Product; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Optional; @RestController @RequestMapping("/products") public class ProductController { private final ProductService service; public ProductController(ProductService service) { this.service = service; } @GetMapping public List<Product> getAll() { return service.getAllProducts(); } @GetMapping("/{id}") public Optional<Product> getById(@PathVariable Long id) { return service.getProductById(id); } @PostMapping public Product create(@RequestBody Product product) { return service.createProduct(product); } } |
βοΈ 5. Configuration
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package com.kscodes.springboot.advanced.config; import com.kscodes.springboot.advanced.application.service.ProductService; import com.kscodes.springboot.advanced.domain.port.ProductRepositoryPort; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class AppConfig { @Bean public ProductService productService(ProductRepositoryPort port) { return new ProductService(port); } } |
π‘ Benefits of Clean + Hexagonal Design
Benefit | Description |
---|---|
Framework-agnostic | Replace Spring with another library without rewriting domain logic |
Testable | Domain and services can be tested without databases or web |
Clear separation | Each layer has a distinct responsibility |
Adaptable | Easily plug in new adapters (e.g., Kafka, REST, gRPC) |
π§ͺ How to Test
- You can mock the
ProductRepositoryPort
in unit tests. - Integration testing becomes easier with clean separation.
- Domain logic can be tested as pure Java classes.
π Conclusion
Implementing Spring Boot Clean Architecture and Hexagonal Design sets the foundation for long-term code maintainability and system agility. By decoupling domain logic from frameworks and delivery channels, you make your application more resilient to change and future-proof.
Start small β modularize your code β and watch your app scale without turning into spaghetti.