Clean architecture with Spring Boot in Java & Spring Boot
Most Spring Boot applications start clean and gradually become tangled as the codebase grows. Controllers reference repositories directly, business logic leaks into endpoints, and by the time you need to reuse a feature outside the web layer, you're already stuck. Clean architecture with Spring Boot in Java & Spring Boot offers a structured way to keep your domain logic independent of framework details, making your code testable, maintainable, and portable.
Why separation of concerns matters in production
We've all seen codebases where swapping PostgreSQL for MongoDB or REST for gRPC requires rewriting half the business logic. That's the cost of tight coupling between framework and domain. Clean architecture inverts this relationship: the framework becomes a detail, not the foundation. Your business rules should work whether you're using Spring MVC, Spring WebFlux, or even a CLI client.
In production systems across e-commerce, trading, and ride-hailing platforms, this separation has proven invaluable. When a payment service needed to support both synchronous REST calls and asynchronous Kafka events, the teams that had isolated their domain logic adapted in days. Those that hadn't faced weeks of refactoring.
Domain package has no Spring imports
Your domain layer is your competitive advantage. It contains the rules that make your business unique: how a discount is calculated, when a ride is accepted, what validates an order. This layer must be completely free of Spring annotations and imports.
Consider an e-commerce order domain. Here's what it should look like:
package com.example.order.domain;
public class Order {
private final String orderId;
private final List<LineItem> items;
private final OrderStatus status;
private final BigDecimal totalAmount;
public Order(String orderId, List<LineItem> items) {
this.orderId = orderId;
this.items = items;
this.status = OrderStatus.PENDING;
this.totalAmount = calculateTotal(items);
}
public void confirm() {
if (status != OrderStatus.PENDING) {
throw new InvalidOrderStateException(
"Cannot confirm order in " + status + " state"
);
}
// State transition logic, no framework involved
}
private BigDecimal calculateTotal(List<LineItem> items) {
return items.stream()
.map(LineItem::subtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public OrderStatus getStatus() {
return status;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
}Notice: no @Entity, no @Column, no Spring context. The Order class is a pure Java object expressing your business rules. This domain code is testable without Spring, portable across different frameworks, and readable to anyone on your team.
Domain exceptions should also live here:
package com.example.order.domain;
public class InvalidOrderStateException extends RuntimeException {
public InvalidOrderStateException(String message) {
super(message);
}
}Use cases orchestrate domain logic
Your use cases (also called application services or interactors) sit between the adapter layer and the domain. They orchestrate domain objects, apply business rules, and coordinate with external systems. They belong in an application layer, not scattered through controllers.
A use case takes input (typically a command or request DTO), uses the domain, and produces output. It handles the "how do I apply this business rule" question. Here's a confirm order use case:
package com.example.order.application;
import com.example.order.domain.Order;
import com.example.order.domain.OrderRepository;
import com.example.order.domain.OrderStatus;
public class ConfirmOrderUseCase {
private final OrderRepository orderRepository;
private final OrderNotificationService notificationService;
public ConfirmOrderUseCase(
OrderRepository orderRepository,
OrderNotificationService notificationService
) {
this.orderRepository = orderRepository;
this.notificationService = notificationService;
}
public void execute(ConfirmOrderCommand command) {
Order order = orderRepository.findById(command.getOrderId())
.orElseThrow(() -> new OrderNotFoundException(command.getOrderId()));
order.confirm();
orderRepository.save(order);
notificationService.notifyOrderConfirmed(order);
}
}
public class ConfirmOrderCommand {
private final String orderId;
public ConfirmOrderCommand(String orderId) {
this.orderId = orderId;
}
public String getOrderId() {
return orderId;
}
}The use case knows about OrderRepository (an interface defined in the domain), but the implementation is injected. The use case orchestrates: fetch, transform, validate, persist, notify. It's testable with a fake repository. No Spring annotations needed.
In a ride-hailing platform, a single use case might need to check driver availability, calculate fare, reserve a vehicle, and publish an event. That orchestration logic belongs in the use case, not scattered through a controller or event handler.
Adapters for web, persistence and messaging
Adapters are where Spring lives. They translate between the outside world (HTTP, databases, message queues) and your application layer. Your REST controller is an adapter. Your database repository implementation is an adapter. Your Kafka consumer is an adapter.
Here's the REST adapter (controller) for confirming an order:
package com.example.order.adapter.web;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import com.example.order.application.ConfirmOrderUseCase;
import com.example.order.application.ConfirmOrderCommand;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final ConfirmOrderUseCase confirmOrderUseCase;
public OrderController(ConfirmOrderUseCase confirmOrderUseCase) {
this.confirmOrderUseCase = confirmOrderUseCase;
}
@PostMapping("/{orderId}/confirm")
public ResponseEntity<Void> confirmOrder(@PathVariable String orderId) {
try {
confirmOrderUseCase.execute(new ConfirmOrderCommand(orderId));
return ResponseEntity.noContent().build();
} catch (OrderNotFoundException e) {
return ResponseEntity.notFound().build();
}
}
}The controller is thin: it extracts input, calls the use case, translates the result to HTTP. It has no business logic. If you needed to add a gRPC endpoint for the same operation, you'd write another adapter that also calls ConfirmOrderUseCase.
The persistence adapter implements the repository interface:
package com.example.order.adapter.persistence;
import org.springframework.stereotype.Repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.example.order.domain.Order;
import com.example.order.domain.OrderRepository;
import java.util.Optional;
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaRepository jpaRepo;
public JpaOrderRepository(OrderJpaRepository jpaRepo) {
this.jpaRepo = jpaRepo;
}
@Override
public Optional<Order> findById(String id) {
return jpaRepo.findById(id)
.map(OrderJpaEntity::toDomain);
}
@Override
public void save(Order order) {
jpaRepo.save(OrderJpaEntity.fromDomain(order));
}
}
interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, String> {
}Notice the mapping: OrderJpaEntity (with JPA annotations) exists only in the adapter. The domain knows nothing about it. You could swap this for a MongoDB adapter or an in-memory repository for testing without changing anything in the application or domain layers.
The JPA entity stays separate:
package com.example.order.adapter.persistence;
import jakarta.persistence.*;
import com.example.order.domain.Order;
import com.example.order.domain.OrderStatus;
import java.math.BigDecimal;
@Entity
@Table(name = "orders")
public class OrderJpaEntity {
@Id
private String orderId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private BigDecimal totalAmount;
// JPA constructors and mapping methods
public static OrderJpaEntity fromDomain(Order order) {
// Map domain object to entity
return new OrderJpaEntity();
}
public Order toDomain() {
// Map entity back to domain object
return new Order(orderId, items);
}
}Do not over-layer small CRUD services
Clean architecture is a tool, not a rule. A service that purely reads users from a database and returns JSON doesn't need orchestration layers and domain entities. Over-engineering here creates friction without benefit.
For a simple read operation, a controller calling a repository directly is fine:
@RestController
@RequestMapping("/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return userRepository.findById(id)
.map(user -> new UserResponse(user.getId(), user.getEmail()))
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}The cost of adding use cases, domain entities, and repository interfaces for every CRUD operation is slower development, more files, and cognitive overhead. Apply clean architecture where complexity exists: where business rules, state transitions, and multi-step workflows justify the structure.
A good heuristic: if your service has no validation, no orchestration, and no business rules, keep it simple. As complexity grows, refactor toward clean architecture.
Common mistakes
- Bleeding Spring into the domain: Using
@Entity,@Transactional, or Spring annotations in domain classes couples them to the framework. The domain should be framework-agnostic. - Thick controllers: Putting business logic in endpoints defeats the purpose. Controllers should extract input and delegate to use cases.
- Use cases that know too much: A use case that directly queries multiple repositories or makes business decisions for two different domains is often a sign you need to split responsibility.
- Skipping the mapping layer: Mapping domain objects to JPA entities or DTOs feels tedious until you need to change your persistence strategy. Then you're grateful for the separation.
- Domain exceptions leaking into adapters: Catch domain exceptions in adapters and translate them to appropriate HTTP status codes or messaging conventions. Don't let
InvalidOrderStateExceptionbubble into a REST response.
Key takeaways
- Keep your domain layer completely free of Spring imports and annotations. It's your core logic, portable and testable on its own.
- Use cases orchestrate domain logic and coordinate with external systems. They sit between adapters and the domain, preventing business rules from leaking into controllers.
- Adapters (controllers, repositories, message handlers) translate between the outside world and your application. They're where Spring lives and where you swap implementations.
- Don't over-engineer simple CRUD operations. Clean architecture is powerful for complex domains; keep it simple where complexity doesn't exist.
- Separate your data model from your domain model. JPA entities belong in the adapter layer, not the domain.
- Testing becomes trivial when layers are decoupled. Test domain logic without mocking, test use cases with fake repositories, test adapters with integration tests.






Comments
Loading comments…