Skip to main content

Clean Architecture — Building Maintainable Java Applications

· 9 min read
Hieu Nguyen
Senior Software Engineer at OCB

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

LayerContainsDepends OnExample
DomainEntities, Value ObjectsNothingOrder, Money, OrderStatus
ApplicationUse Cases, Port InterfacesDomainCreateOrderUseCase, OrderRepository (interface)
AdapterControllers, Repository Impls, DTOsApplication, DomainOrderController, JpaOrderRepository
InfrastructureFrameworks, Config, External APIsEverythingSpring 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 @Configuration class 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

AspectLayered (Traditional)Clean Architecture
Dependency directionTop → down (Controller → Service → Repository)Outside → in (all depend on Domain)
Domain depends onJPA, Spring annotationsNothing
TestingNeeds Spring context for most testsDomain + Use Cases testable without Spring
Switching DBChange Service + RepositoryChange only Adapter (Repository impl)
Switching frameworkRewrite everythingChange only Infrastructure + Adapters
BoilerplateLess codeMore code (ports, mappers)
Best forSimple CRUD appsComplex business logic, long-lived projects

Common Pitfalls

PitfallProblemSolution
JPA annotations in DomainDomain depends on frameworkKeep Domain as pure POJOs, use separate JPA Entities in Adapter
Skipping PortsController calls repository directlyAlways go through Use Case → Port
Over-engineering CRUD4 layers for a simple GET by IDApply Clean Architecture selectively — simple CRUD can use shortcuts
Leaking DTOs into Use CasesUse case accepts @RequestBody objectsUse Command/Query objects as use case input
Circular dependenciesApplication layer imports Adapter classesUse 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

  1. Dependencies point inward — Domain knows nothing about Spring, JPA, or HTTP. Ever.
  2. Domain layer is a plain POJO — no framework annotations, testable with plain JUnit.
  3. Ports define boundaries — Input Ports (what the app does) and Output Ports (what the app needs).
  4. Adapters are replaceable — switch from MySQL to MongoDB by writing a new Repository adapter.
  5. Use Cases contain application flow — orchestrate domain objects and ports, not controllers.
  6. 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. 🚀