As enterprise applications grow in size and complexity, maintaining clean boundaries between business logic and technical concerns becomes increasingly difficult. Tight coupling between frameworks, databases, and domain logic often leads to fragile systems that are hard to test, extend, and refactor. This is exactly the problem that Clean Architecture Spring Boot solutions aim to solve.
Clean Architecture, popularized by Robert C. Martin, promotes a design where business rules are independent of frameworks, UI, databases, and external systems. When combined with the Ports and Adapters Java approach and aligned with DDD Architecture principles, it becomes a powerful blueprint for building highly maintainable enterprise Spring Boot applications.
This post provides a complete, production-ready guide for software engineers who want to implement Clean Architecture in a real Spring Boot application. The solution is built step by step with fully executable and compilable code and includes proper testing.
Why Clean Architecture Matters in Enterprise Spring Boot Applications
In many real-world Spring Boot projects, domain logic is tightly coupled with Spring annotations, JPA entities, and REST controllers. Over time, this coupling makes systems hard to test, fragile during upgrades, and difficult to extend. Clean Architecture Spring Boot enforces strict separation of concerns, ensuring business rules remain independent and long-lived.
This architectural style improves maintainability, scalability, testability, and long-term stability, making it ideal for enterprise systems.
Clean Architecture and Ports and Adapters Explained
Clean Architecture organizes code into concentric layers where dependencies always point inward. The core contains domain entities and business rules, while outer layers handle frameworks, databases, and delivery mechanisms. The Ports and Adapters Java pattern defines how these layers communicate using interfaces and implementations.
This approach aligns naturally with DDD Architecture, keeping the domain model expressive, isolated, and protected from technical details.
Architecture Overview of the Example Application
The example application demonstrates a customer management use case implemented using Clean Architecture principles. Although the domain is simple, the structure mirrors real enterprise applications with complex workflows and long lifecycles.
- Domain logic is framework-independent
- Use cases are isolated from infrastructure
- Spring Boot is used only for wiring and adapters
- JPA and REST are treated as replaceable details
Step 1: Create the Spring Boot Project
Create a new Spring Boot project using Maven and Java 17. Add Spring Web, Spring Data JPA, H2 Database, and Spring Boot Test dependencies. This configuration ensures the project is fully executable and suitable for enterprise-style testing.
Step 2: Define the Package Structure
A clear package structure is essential to enforce architectural boundaries:
- domain (model, port)
- application (service)
- adapter (web, persistence)
- configuration
This structure prevents accidental coupling and makes responsibilities explicit.
Step 3: Create the Domain Model
package com.example.cleanarchitecture.domain.model;
public class Customer {
private final Long id;
private final String name;
public Customer(Long id, String name) {
this.id = id;
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
This domain entity contains no Spring or JPA annotations, ensuring full independence from frameworks.
Step 4: Define Domain Ports
package com.example.cleanarchitecture.domain.port;
import com.example.cleanarchitecture.domain.model.Customer;
import java.util.List;
public interface CustomerRepositoryPort {
Customer save(Customer customer);
List findAll();
}
Ports define what the domain needs without specifying how it is implemented.
Step 5: Implement the Application Use Case
package com.example.cleanarchitecture.application.service;
import com.example.cleanarchitecture.domain.model.Customer;
import com.example.cleanarchitecture.domain.port.CustomerRepositoryPort;
import java.util.List;
public class CustomerService {
private final CustomerRepositoryPort repositoryPort;
public CustomerService(CustomerRepositoryPort repositoryPort) {
this.repositoryPort = repositoryPort;
}
public Customer createCustomer(String name) {
return repositoryPort.save(new Customer(null, name));
}
public List getCustomers() {
return repositoryPort.findAll();
}
}
This service represents a use case and remains framework-agnostic.
Step 6: Create the Persistence Adapter
package com.example.cleanarchitecture.adapter.persistence;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
@Entity
class CustomerEntity {
@Id
@GeneratedValue
private Long id;
String name;
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
package com.example.cleanarchitecture.adapter.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
interface JpaCustomerRepository extends JpaRepository<CustomerEntity, Long> {
}
package com.example.cleanarchitecture.adapter.persistence;
import com.example.cleanarchitecture.domain.model.Customer;
import com.example.cleanarchitecture.domain.port.CustomerRepositoryPort;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class CustomerRepositoryAdapter implements CustomerRepositoryPort {
private final JpaCustomerRepository jpaRepository;
public CustomerRepositoryAdapter(JpaCustomerRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Customer save(Customer customer) {
CustomerEntity entity = new CustomerEntity();
entity.name = customer.getName();
CustomerEntity saved = jpaRepository.save(entity);
return new Customer(saved.getId(), saved.getName());
}
@Override
public List<Customer> findAll() {
return jpaRepository.findAll()
.stream()
.map(e -> new Customer(e.getId(), e.getName()))
.collect(Collectors.toList());
}
}
Step 7: Create the Web Adapter
package com.example.cleanarchitecture.adapter.web;
import com.example.cleanarchitecture.application.service.CustomerService;
import com.example.cleanarchitecture.domain.model.Customer;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/customers")
public class CustomerController {
private final CustomerService service;
public CustomerController(CustomerService service) {
this.service = service;
}
@PostMapping
public Customer create(@RequestParam String name) {
return service.createCustomer(name);
}
@GetMapping
public List<Customer> findAll() {
return service.getCustomers();
}
}
Step 8: Wire Dependencies Using Configuration
package com.example.cleanarchitecture.configuration;
import com.example.cleanarchitecture.application.service.CustomerService;
import com.example.cleanarchitecture.domain.port.CustomerRepositoryPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ApplicationConfig {
@Bean
CustomerService customerService(CustomerRepositoryPort port) {
return new CustomerService(port);
}
}
Step 9: Main Application Class
package com.example.cleanarchitecture;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CleanArchitectureApplication {
public static void main(String[] args) {
SpringApplication.run(CleanArchitectureApplication.class, args);
}
}
Step 10: How to Test the Application
Start the Spring Boot application. Create a customer using POST /customers?name=John and retrieve customers using GET /customers. The response confirms correct wiring of ports and adapters.
Step 11: Automated Test Case
package com.example.cleanarchitecture;
import com.example.cleanarchitecture.application.service.CustomerService;
import com.example.cleanarchitecture.domain.port.CustomerRepositoryPort;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerServiceTest {
@Test
void testCreateCustomer() {
CustomerRepositoryPort fakeRepo = new CustomerRepositoryPort() {
private final List<com.example.cleanarchitecture.domain.model.Customer> data = new ArrayList<>();
@Override
public com.example.cleanarchitecture.domain.model.Customer save(
com.example.cleanarchitecture.domain.model.Customer customer) {
data.add(customer);
return customer;
}
@Override
public List<com.example.cleanarchitecture.domain.model.Customer> findAll() {
return data;
}
};
CustomerService service = new CustomerService(fakeRepo);
service.createCustomer("TestUser");
assertThat(service.getCustomers()).hasSize(1);
}
}
Conclusion
Implementing Clean Architecture Spring Boot using Ports and Adapters Java and aligning with DDD Architecture principles provides a strong foundation for enterprise applications. By keeping business rules isolated and treating frameworks as details, teams gain long-term maintainability, easier testing, and safer evolution of complex systems.
