Clean Architecture in Java: Beyond SOLID (2025)

Clean Architecture provides a powerful approach to building maintainable and testable applications by focusing on separation of concerns and dependency inversion. This comprehensive guide explores advanced Clean Architecture patterns and their implementation in Java.
Pro Tip: Understanding Clean Architecture patterns helps developers create more maintainable and testable applications by properly separating concerns.
Table of Contents
Clean Architecture Basics
Note: Clean Architecture emphasizes the importance of separating business logic from external concerns.
Core Concepts
// Entity
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List orderLines;
private OrderStatus status;
public Order(OrderId id, CustomerId customerId) {
this.id = id;
this.customerId = customerId;
this.orderLines = new ArrayList<>();
this.status = OrderStatus.DRAFT;
}
public void addOrderLine(Product product, int quantity) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify non-draft order");
}
orderLines.add(new OrderLine(product, quantity));
}
}
// Use Case
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
public CreateOrderUseCase(OrderRepository orderRepository, CustomerRepository customerRepository) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
}
public Order execute(CreateOrderRequest request) {
if (!customerRepository.exists(request.getCustomerId())) {
throw new CustomerNotFoundException(request.getCustomerId());
}
Order order = new Order(new OrderId(), request.getCustomerId());
request.getOrderLines().forEach(line ->
order.addOrderLine(line.getProduct(), line.getQuantity())
);
return orderRepository.save(order);
}
}
Dependency Inversion
Pro Tip: Dependency inversion ensures that high-level modules don't depend on low-level modules.
Dependency Inversion Implementation
// Port (Interface)
public interface OrderRepository {
Order save(Order order);
Optional findById(OrderId id);
List findByCustomerId(CustomerId customerId);
}
// Adapter Implementation
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public JpaOrderRepository(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderEntity entity = OrderMapper.toEntity(order);
OrderEntity savedEntity = jpaRepository.save(entity);
return OrderMapper.toDomain(savedEntity);
}
@Override
public Optional findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(OrderMapper::toDomain);
}
}
Ports and Adapters
Note: Ports and Adapters pattern (Hexagonal Architecture) helps isolate the application core from external concerns.
Ports and Adapters Implementation
// Input Port
public interface CreateOrderPort {
Order createOrder(CreateOrderRequest request);
}
// Output Port
public interface OrderRepositoryPort {
Order save(Order order);
Optional findById(OrderId id);
}
// Primary Adapter (Controller)
@RestController
@RequestMapping("/api/orders")
public class OrderController implements CreateOrderPort {
private final CreateOrderUseCase createOrderUseCase;
public OrderController(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
@PostMapping
public ResponseEntity createOrder(@RequestBody CreateOrderRequest request) {
Order order = createOrderUseCase.execute(request);
return ResponseEntity.ok(OrderResponse.from(order));
}
}
// Secondary Adapter (Repository)
@Repository
public class JpaOrderRepository implements OrderRepositoryPort {
private final OrderJpaRepository jpaRepository;
public JpaOrderRepository(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Order save(Order order) {
OrderEntity entity = OrderMapper.toEntity(order);
OrderEntity savedEntity = jpaRepository.save(entity);
return OrderMapper.toDomain(savedEntity);
}
}
Use Cases
Pro Tip: Use cases represent the application's business rules and orchestrate the flow of data.
Use Case Implementation
// Use Case Input
public class CreateOrderRequest {
private final CustomerId customerId;
private final List orderLines;
}
// Use Case Output
public class CreateOrderResponse {
private final OrderId orderId;
private final OrderStatus status;
private final Money totalAmount;
}
// Use Case Implementation
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
private final OrderPresenter orderPresenter;
public CreateOrderUseCase(
OrderRepository orderRepository,
CustomerRepository customerRepository,
OrderPresenter orderPresenter) {
this.orderRepository = orderRepository;
this.customerRepository = customerRepository;
this.orderPresenter = orderPresenter;
}
public CreateOrderResponse execute(CreateOrderRequest request) {
if (!customerRepository.exists(request.getCustomerId())) {
throw new CustomerNotFoundException(request.getCustomerId());
}
Order order = new Order(new OrderId(), request.getCustomerId());
request.getOrderLines().forEach(line ->
order.addOrderLine(line.getProduct(), line.getQuantity())
);
Order savedOrder = orderRepository.save(order);
return orderPresenter.present(savedOrder);
}
}
Presenters
Note: Presenters transform domain objects into view models suitable for the presentation layer.
Presenter Implementation
public interface OrderPresenter {
CreateOrderResponse present(Order order);
OrderListResponse present(List orders);
}
public class OrderPresenterImpl implements OrderPresenter {
@Override
public CreateOrderResponse present(Order order) {
return new CreateOrderResponse(
order.getId(),
order.getStatus(),
order.calculateTotal()
);
}
@Override
public OrderListResponse present(List orders) {
return new OrderListResponse(
orders.stream()
.map(this::present)
.collect(Collectors.toList())
);
}
}
Repositories
Pro Tip: Repositories abstract the persistence mechanism and provide a collection-like interface for domain objects.
Repository Implementation
// Repository Interface
public interface OrderRepository {
Order save(Order order);
Optional findById(OrderId id);
List findByCustomerId(CustomerId customerId);
void delete(OrderId id);
}
// JPA Repository Implementation
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
private final OrderMapper mapper;
public JpaOrderRepository(OrderJpaRepository jpaRepository, OrderMapper mapper) {
this.jpaRepository = jpaRepository;
this.mapper = mapper;
}
@Override
public Order save(Order order) {
OrderEntity entity = mapper.toEntity(order);
OrderEntity savedEntity = jpaRepository.save(entity);
return mapper.toDomain(savedEntity);
}
@Override
public Optional findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(mapper::toDomain);
}
@Override
public List findByCustomerId(CustomerId customerId) {
return jpaRepository.findByCustomerId(customerId.getValue())
.stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
}
}
Testing
Note: Clean Architecture makes testing easier by allowing you to test each layer independently.
Testing Implementation
// Use Case Test
@ExtendWith(MockitoExtension.class)
class CreateOrderUseCaseTest {
@Mock
private OrderRepository orderRepository;
@Mock
private CustomerRepository customerRepository;
@Mock
private OrderPresenter orderPresenter;
@InjectMocks
private CreateOrderUseCase createOrderUseCase;
@Test
void shouldCreateOrder() {
// Arrange
CreateOrderRequest request = new CreateOrderRequest(
new CustomerId("CUST-1"),
List.of(new OrderLineRequest(new ProductId("PROD-1"), 2))
);
when(customerRepository.exists(any())).thenReturn(true);
when(orderRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(orderPresenter.present(any())).thenReturn(new CreateOrderResponse(/* ... */));
// Act
CreateOrderResponse response = createOrderUseCase.execute(request);
// Assert
verify(customerRepository).exists(request.getCustomerId());
verify(orderRepository).save(any(Order.class));
verify(orderPresenter).present(any(Order.class));
}
}
// Repository Test
@SpringBootTest
class JpaOrderRepositoryTest {
@Autowired
private JpaOrderRepository repository;
@Autowired
private OrderJpaRepository jpaRepository;
@Test
void shouldSaveAndRetrieveOrder() {
// Arrange
Order order = new Order(new OrderId(), new CustomerId("CUST-1"));
order.addOrderLine(new Product(new ProductId("PROD-1")), 2);
// Act
Order savedOrder = repository.save(order);
Optional retrievedOrder = repository.findById(savedOrder.getId());
// Assert
assertTrue(retrievedOrder.isPresent());
assertEquals(savedOrder.getId(), retrievedOrder.get().getId());
}
}
Best Practices
Pro Tip: Following Clean Architecture best practices ensures maintainable and testable applications.
Best Practices Summary
- Keep the domain layer pure and independent
- Use dependency inversion for external dependencies
- Implement ports and adapters for external concerns
- Keep use cases focused and single-purpose
- Use presenters for view model transformation
- Implement repositories for persistence abstraction
- Write comprehensive tests for each layer
- Use appropriate naming conventions
Conclusion
Clean Architecture provides a powerful approach to building maintainable and testable applications. By understanding and implementing advanced Clean Architecture patterns, developers can create more robust applications that are easier to maintain and extend.