Clean Architecture — Building Maintainable Java Applications
A practical guide to Clean Architecture in Java — with real project structure, layer responsibilities, and a complete Spring Boot example that you can apply to production services.
Have you ever worked on a codebase where changing the database required modifying dozens of files? Or where business logic was scattered across controllers, services, and repositories — making it impossible to write unit tests without spinning up the entire application?
I've dealt with this in banking systems where a single change to a payment flow could break unrelated features because everything was tightly coupled. Clean Architecture solves this by organizing code into layers with strict dependency rules, making your application easy to test, maintain, and evolve.
📖 Clean Architecture by Robert C. Martin (2017) • Hexagonal Architecture by Alistair Cockburn
The Core Idea
Clean Architecture is built on one fundamental rule:
Dependencies must point inward. Inner layers know nothing about outer layers.
┌──────────────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ ┌──────────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ Application Layer │ │ │
│ │ │ ┌──────────────────────────────┐ │ │ │
│ │ │ │ Domain Layer │ │ │ │
│ │ │ │ (Entities + Use Cases) │ │ │ │
│ │ │ └──────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
The innermost circle contains your business rules — they don't depend on any framework, database, or HTTP library. The outer circles are details (Spring, JPA, REST controllers) that can be swapped without touching business logic.
The Four Layers
| Layer | Contains | Depends On | Example |
|---|---|---|---|
| Domain | Entities, Value Objects | Nothing | Order, Money, OrderStatus |
| Application | Use Cases, Port Interfaces | Domain | CreateOrderUseCase, OrderRepository (interface) |
| Adapter | Controllers, Repository Impls, DTOs | Application, Domain | OrderController, JpaOrderRepository |
| Infrastructure | Frameworks, Config, External APIs | Everything | Spring Boot, JPA, Kafka, Redis config |
The Dependency Rule in Practice
Controller → UseCase → Entity
↓ ↓
DTO/Request Port (interface)
↑
JpaRepository (implements Port)
- Controllers call Use Cases (not repositories directly)
- Use Cases depend on Port interfaces (not JPA or any framework)
- Repository implementations implement the Port — injected at runtime by Spring
Project Structure
Here's how I structure Clean Architecture in a real Spring Boot project:
src/main/java/com/hoclamdev/order/
├── domain/ ← 🟢 Innermost — no dependencies
│ ├── model/
│ │ ├── Order.java ← Entity (POJO, no annotations)
│ │ ├── OrderItem.java
│ │ ├── OrderStatus.java ← Enum
│ │ └── Money.java ← Value Object
│ ├── exception/
│ │ ├── OrderNotFoundException.java
│ │ └── InsufficientStockException.java
│ └── service/
│ └── OrderDomainService.java ← Domain logic (pure business rules)
│
├── application/ ← 🟡 Use Cases + Ports
│ ├── port/
│ │ ├── in/
│ │ │ ├── CreateOrderUseCase.java ← Input port (interface)
│ │ │ └── GetOrderUseCase.java
│ │ └── out/
│ │ ├── OrderRepository.java ← Output port (interface)
│ │ ├── PaymentGateway.java ← Output port (interface)
│ │ └── NotificationSender.java
│ └── service/
│ ├── CreateOrderService.java ← Use case implementation
│ └── GetOrderService.java
│
├── adapter/ ← 🟠 Controllers, Repo impls
│ ├── in/
│ │ └── web/
│ │ ├── OrderController.java ← REST controller
│ │ ├── CreateOrderRequest.java ← DTO
│ │ └── OrderResponse.java ← DTO
│ └── out/
│ ├── persistence/
│ │ ├── JpaOrderRepository.java ← Implements OrderRepository
│ │ ├── OrderEntity.java ← JPA entity
│ │ └── OrderJpaRepository.java ← Spring Data interface
│ └── external/
│ ├── StripePaymentGateway.java ← Implements PaymentGateway
│ └── EmailNotificationSender.java
│
└── infrastructure/ ← 🔴 Framework config
├── config/
│ ├── BeanConfig.java ← Wire use cases to ports
│ └── SecurityConfig.java
└── OrderApplication.java ← @SpringBootApplication
Key insight: The
domain/package has zero Spring annotations — no@Entity, no@Service, no@Autowired. It's pure Java. This means you can test domain logic with plain JUnit — no Spring context needed.
Code Example — Order Service
Let me walk through a complete example: creating an order.
Domain Layer
The domain layer contains pure business objects with no framework dependencies:
// domain/model/Order.java — Pure POJO, no JPA annotations
public class Order {
private String id;
private String customerId;
private List<OrderItem> items;
private OrderStatus status;
private Money totalAmount;
private LocalDateTime createdAt;
public Order(String customerId, List<OrderItem> items) {
this.id = UUID.randomUUID().toString();
this.customerId = customerId;
this.items = items;
this.status = OrderStatus.CREATED;
this.totalAmount = calculateTotal();
this.createdAt = LocalDateTime.now();
}
private Money calculateTotal() {
return items.stream()
.map(item -> item.getPrice().multiply(item.getQuantity()))
.reduce(Money.ZERO, Money::add);
}
public void confirm() {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("Can only confirm CREATED orders");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancel() {
if (this.status == OrderStatus.SHIPPED) {
throw new IllegalStateException("Cannot cancel shipped orders");
}
this.status = OrderStatus.CANCELLED;
}
// Getters (no setters — immutable where possible)
}
// domain/model/Money.java — Value Object
public record Money(BigDecimal amount, String currency) {
public static final Money ZERO = new Money(BigDecimal.ZERO, "USD");
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
}
Application Layer — Ports & Use Cases
Define what the application can do (Input Ports) and what it needs (Output Ports):
// application/port/in/CreateOrderUseCase.java — Input Port
public interface CreateOrderUseCase {
Order execute(CreateOrderCommand command);
// Command is a simple data carrier — no framework dependency
record CreateOrderCommand(
String customerId,
List<OrderItemCommand> items
) {}
record OrderItemCommand(
String productId,
String productName,
BigDecimal price,
int quantity
) {}
}
// application/port/out/OrderRepository.java — Output Port
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
List<Order> findByCustomerId(String customerId);
}
// application/port/out/PaymentGateway.java — Output Port
public interface PaymentGateway {
PaymentResult charge(String customerId, Money amount);
}
The Use Case implementation contains the application's business flow:
// application/service/CreateOrderService.java — Use Case Implementation
public class CreateOrderService implements CreateOrderUseCase {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
// Constructor injection — no @Autowired needed here
public CreateOrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
}
@Override
public Order execute(CreateOrderCommand command) {
// 1. Build domain objects
List<OrderItem> items = command.items().stream()
.map(i -> new OrderItem(i.productId(), i.productName(),
new Money(i.price(), "USD"), i.quantity()))
.toList();
Order order = new Order(command.customerId(), items);
// 2. Process payment
PaymentResult payment = paymentGateway.charge(
command.customerId(), order.getTotalAmount()
);
if (!payment.isSuccess()) {
throw new PaymentFailedException(payment.getErrorMessage());
}
// 3. Confirm and save
order.confirm();
orderRepository.save(order);
return order;
}
}
Adapter Layer — Controllers & Repository Implementations
REST Controller (inbound adapter):
// adapter/in/web/OrderController.java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final CreateOrderUseCase createOrderUseCase;
public OrderController(CreateOrderUseCase createOrderUseCase) {
this.createOrderUseCase = createOrderUseCase;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
// Convert DTO → Command (adapter responsibility)
var command = new CreateOrderUseCase.CreateOrderCommand(
request.customerId(),
request.items().stream()
.map(i -> new CreateOrderUseCase.OrderItemCommand(
i.productId(), i.productName(), i.price(), i.quantity()
))
.toList()
);
Order order = createOrderUseCase.execute(command);
// Convert Domain → Response DTO
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponse.from(order));
}
}
JPA Repository (outbound adapter):
// adapter/out/persistence/JpaOrderRepository.java
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepository;
public JpaOrderRepository(OrderJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public void save(Order order) {
// Convert Domain → JPA Entity
OrderEntity entity = OrderEntity.from(order);
jpaRepository.save(entity);
}
@Override
public Optional<Order> findById(String id) {
return jpaRepository.findById(id)
.map(OrderEntity::toDomain); // Convert JPA Entity → Domain
}
@Override
public List<Order> findByCustomerId(String customerId) {
return jpaRepository.findByCustomerId(customerId).stream()
.map(OrderEntity::toDomain)
.toList();
}
}
// adapter/out/persistence/OrderEntity.java — JPA entity (separate from Domain!)
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private String id;
private String customerId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
private String currency;
private LocalDateTime createdAt;
// Mapping methods
public static OrderEntity from(Order domain) {
OrderEntity entity = new OrderEntity();
entity.id = domain.getId();
entity.customerId = domain.getCustomerId();
entity.status = domain.getStatus();
entity.totalAmount = domain.getTotalAmount().amount();
entity.currency = domain.getTotalAmount().currency();
entity.createdAt = domain.getCreatedAt();
return entity;
}
public Order toDomain() {
// Reconstruct domain object from persisted data
return new Order(this.id, this.customerId, this.status,
new Money(this.totalAmount, this.currency), this.createdAt);
}
}
Infrastructure — Wiring Everything Together
// infrastructure/config/BeanConfig.java
@Configuration
public class BeanConfig {
@Bean
public CreateOrderUseCase createOrderUseCase(
OrderRepository orderRepository,
PaymentGateway paymentGateway) {
return new CreateOrderService(orderRepository, paymentGateway);
}
@Bean
public GetOrderUseCase getOrderUseCase(OrderRepository orderRepository) {
return new GetOrderService(orderRepository);
}
}
Why a
@Configurationclass instead of@Service? Because our use case classes (CreateOrderService) live in the Application layer and have no Spring annotations. The Infrastructure layer is responsible for wiring dependencies — keeping Spring contained in the outermost circle.
Testing Benefits
Clean Architecture makes testing significantly easier:
Unit Test — Domain (No Spring Needed)
@Test
void shouldCalculateOrderTotal() {
List<OrderItem> items = List.of(
new OrderItem("p1", "Keyboard", new Money(new BigDecimal("50"), "USD"), 2),
new OrderItem("p2", "Mouse", new Money(new BigDecimal("30"), "USD"), 1)
);
Order order = new Order("customer-1", items);
// Pure logic — no mocks, no Spring context
assertEquals(new BigDecimal("130"), order.getTotalAmount().amount());
assertEquals(OrderStatus.CREATED, order.getStatus());
}
Unit Test — Use Case (Mock Ports Only)
@ExtendWith(MockitoExtension.class)
class CreateOrderServiceTest {
@Mock private OrderRepository orderRepository;
@Mock private PaymentGateway paymentGateway;
@InjectMocks private CreateOrderService createOrderService;
@Test
void shouldCreateOrderSuccessfully() {
// Given
var command = new CreateOrderUseCase.CreateOrderCommand(
"customer-1",
List.of(new CreateOrderUseCase.OrderItemCommand(
"p1", "Keyboard", new BigDecimal("50"), 2
))
);
when(paymentGateway.charge(anyString(), any()))
.thenReturn(PaymentResult.success("txn-123"));
// When
Order order = createOrderService.execute(command);
// Then
assertEquals(OrderStatus.CONFIRMED, order.getStatus());
verify(orderRepository).save(any(Order.class));
verify(paymentGateway).charge(eq("customer-1"), any(Money.class));
}
}
No @SpringBootTest, no database, no HTTP server — tests run in milliseconds.
Clean Architecture vs Layered Architecture
| Aspect | Layered (Traditional) | Clean Architecture |
|---|---|---|
| Dependency direction | Top → down (Controller → Service → Repository) | Outside → in (all depend on Domain) |
| Domain depends on | JPA, Spring annotations | Nothing |
| Testing | Needs Spring context for most tests | Domain + Use Cases testable without Spring |
| Switching DB | Change Service + Repository | Change only Adapter (Repository impl) |
| Switching framework | Rewrite everything | Change only Infrastructure + Adapters |
| Boilerplate | Less code | More code (ports, mappers) |
| Best for | Simple CRUD apps | Complex business logic, long-lived projects |
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| JPA annotations in Domain | Domain depends on framework | Keep Domain as pure POJOs, use separate JPA Entities in Adapter |
| Skipping Ports | Controller calls repository directly | Always go through Use Case → Port |
| Over-engineering CRUD | 4 layers for a simple GET by ID | Apply Clean Architecture selectively — simple CRUD can use shortcuts |
| Leaking DTOs into Use Cases | Use case accepts @RequestBody objects | Use Command/Query objects as use case input |
| Circular dependencies | Application layer imports Adapter classes | Use dependency inversion — Application defines interfaces, Adapters implement them |
When to Use (and When Not To)
✅ Use Clean Architecture when:
- Business logic is complex (financial systems, order processing, workflows)
- The project will be maintained for years
- You need comprehensive unit testing
- You might switch frameworks, databases, or external services
- Multiple teams work on different parts of the system
❌ Don't use Clean Architecture when:
- Building a simple CRUD API (Spring Data REST is enough)
- Prototyping or building an MVP
- The project has a short lifespan
- You're the only developer and the domain is simple
Pragmatic advice: Start with standard layered architecture. When business logic grows complex and testing becomes painful, refactor toward Clean Architecture incrementally — extract domain models first, then introduce ports.
Key Takeaways
- Dependencies point inward — Domain knows nothing about Spring, JPA, or HTTP. Ever.
- Domain layer is a plain POJO — no framework annotations, testable with plain JUnit.
- Ports define boundaries — Input Ports (what the app does) and Output Ports (what the app needs).
- Adapters are replaceable — switch from MySQL to MongoDB by writing a new Repository adapter.
- Use Cases contain application flow — orchestrate domain objects and ports, not controllers.
- Don't over-apply — simple CRUD doesn't need 4 layers. Apply Clean Architecture where complexity justifies it.
Thanks for reading! If you found this useful, also check out my SOLID Principles post (Clean Architecture builds on SOLID) and my Saga Pattern guide for managing distributed transactions. 🚀