I’ve worked on Spring Boot applications for years. Early on, my code often became a tangled mess. Business logic was sprinkled in controllers, database entities were exposed everywhere, and changing one thing would break three others. It was hard to test and a nightmare to maintain. Then I learned about Clean Architecture. It’s not a framework or a library; it’s a set of ideas for organizing your code so that the core of your application—your business rules—stands independent from databases, web frameworks, and other technical details.
Applying these ideas with Spring Boot changed everything. It brought structure, clarity, and long-term flexibility to my projects. Let me share ten practical techniques that helped me build better systems.
The most important rule is to keep your business logic pure. Your domain entities—like Order, Customer, or Invoice—should be plain Java objects. They should not know about Spring, databases, or HTTP. This means no @Entity, @Table, or @RestController annotations inside them.
// This is a domain entity. It's just Java.
public class Order {
private OrderId id;
private CustomerId customerId;
private List<OrderItem> items;
private Money totalAmount;
private OrderStatus status;
public void addItem(Product product, int quantity) {
// This is a business rule.
if (quantity <= 0) {
throw new InvalidQuantityException();
}
if (product.isOutOfStock()) {
throw new ProductUnavailableException();
}
items.add(new OrderItem(product, quantity));
this.totalAmount = calculateTotal();
}
public void ship() {
if (status != OrderStatus.PAID) {
throw new OrderCannotBeShippedException();
}
this.status = OrderStatus.SHIPPED;
// A domain event can be raised here.
}
}
Why does this matter? Because business rules are the reason your application exists. They should be the easiest part of your system to understand and test. You should be able to run unit tests on this Order class without starting a Spring context or a database. If you ever need to switch from Spring Boot to something else, this core logic remains untouched.
Once your domain is solid, you need a way to execute specific tasks or use cases. This is where application services come in. Think of them as the conductors of an orchestra. They don’t play an instrument themselves; they coordinate the domain objects and outside services to get a job done.
// This service orchestrates the "place order" use case.
@Service
@Transactional
public class PlaceOrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final InventoryClient inventoryClient;
public OrderId execute(PlaceOrderCommand command) {
// 1. Create the domain entity.
Order newOrder = new Order(command.getCustomerId());
// 2. Apply business logic via the entity.
for (OrderItemCommand item : command.getItems()) {
Product product = inventoryClient.getProduct(item.getProductId());
newOrder.addItem(product, item.getQuantity());
}
// 3. Persist the result.
orderRepository.save(newOrder);
// 4. Call external systems.
paymentGateway.charge(newOrder.getTotalAmount(), command.getPaymentToken());
// 5. Return an identifier.
return newOrder.getId();
}
}
I make my services small and focused. Each one typically handles a single user action, like placing an order, canceling an order, or updating a profile. This makes them easy to reason about. The @Transactional annotation is one of the few framework touches here, managing the database transaction boundary for the whole operation.
Your core logic will need to talk to the outside world—to save to a database, send an email, or call another service. The key is to define what you need first, not how you’ll do it. You define an interface in your domain or application layer. This is called a port.
// This port is defined in the application layer.
// It says: "I need a way to save and find Orders."
public interface OrderRepository {
Optional<Order> findById(OrderId id);
Order save(Order order);
List<Order> findByCustomerId(CustomerId customerId);
}
The implementation, the adapter, lives in the infrastructure layer, far away from the core.
// This adapter implements the port using JPA and Hibernate.
@Repository
public class JpaOrderRepository implements OrderRepository {
private final OrderJpaEntityRepository jpaRepository;
private final OrderEntityMapper mapper;
@Override
public Order save(Order order) {
OrderJpaEntity entity = mapper.toJpaEntity(order);
OrderJpaEntity savedEntity = jpaRepository.save(entity);
return mapper.toDomain(savedEntity);
}
@Override
public Optional<Order> findById(OrderId id) {
return jpaRepository.findById(id.getValue())
.map(mapper::toDomain);
}
}
This technique, called Dependency Inversion, is powerful. My PlaceOrderService depends only on the OrderRepository interface. It doesn’t know or care if orders are stored in PostgreSQL, MongoDB, or a text file. Tomorrow, I could write a MongoOrderRepository and swap it in without changing a single line of business code.
Controllers have one job: to be the bridge between the web and your application. They should be thin. Their responsibilities are to accept an HTTP request, validate the basic format of the data, convert it into a command for your application service, and then return an appropriate HTTP response.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final PlaceOrderService placeOrderService;
private final CancelOrderService cancelOrderService;
@PostMapping
public ResponseEntity<OrderIdResponse> placeOrder(@Valid @RequestBody PlaceOrderRequest request) {
// Convert the HTTP request (DTO) into an application command.
PlaceOrderCommand command = new PlaceOrderCommand(
new CustomerId(request.getCustomerId()),
request.getItems().stream()
.map(reqItem -> new OrderItemCommand(
new ProductId(reqItem.getProductId()),
reqItem.getQuantity()))
.toList(),
request.getPaymentToken()
);
// Delegate the work to the application service.
OrderId orderId = placeOrderService.execute(command);
// Return an HTTP response.
return ResponseEntity
.created(URI.create("/api/orders/" + orderId.getValue()))
.body(new OrderIdResponse(orderId.getValue()));
}
}
I keep all the web-specific annotations (@PostMapping, @RequestBody, ResponseEntity) confined to this class. The @Valid annotation triggers Bean Validation on the request DTO, catching simple input errors immediately. The controller’s role is clear, which makes it easy to test with MockMvc.
Spring Boot’s magic @SpringBootApplication and component scanning are great for getting started. But for complex applications, I prefer explicit configuration. It gives me complete control over how my beans are created and wired together.
@Configuration
public class OrderModuleConfiguration {
@Bean
public OrderRepository orderRepository(OrderJpaEntityRepository jpaRepo, OrderEntityMapper mapper) {
return new JpaOrderRepository(jpaRepo, mapper);
}
@Bean
public PaymentGateway paymentGateway(PaymentGatewayProperties properties) {
// I can choose the implementation based on a property.
if ("stripe".equals(properties.getProvider())) {
return new StripePaymentGateway(properties.getApiKey());
} else {
return new PayPalPaymentGateway(properties.getClientId(), properties.getSecret());
}
}
@Bean
public PlaceOrderService placeOrderService(OrderRepository orderRepo,
PaymentGateway gateway,
InventoryClient inventoryClient) {
// All dependencies are clear and injected explicitly.
return new PlaceOrderService(orderRepo, gateway, inventoryClient);
}
}
This approach makes the dependencies between components visible in the code. It’s like a wiring diagram. When I look at this configuration class, I instantly know what a PlaceOrderService needs to work. It also makes unit testing much simpler, as I can create these objects manually in a test without any Spring magic.
In a real application, things happen as a consequence of other things. When an order is shipped, we might need to send a notification, update a logistics dashboard, and reward customer loyalty points. Instead of making the shipOrder service call all these other systems directly, I use events.
First, I define a simple event in the domain.
public class OrderShippedEvent {
private final OrderId orderId;
private final Instant shippedAt;
// Constructor and getters...
}
Then, my domain entity can raise this event when the relevant action occurs.
public class Order {
// ... fields and other methods ...
private final List<DomainEvent> domainEvents = new ArrayList<>();
public void ship() {
// ... business logic to transition status ...
this.status = OrderStatus.SHIPPED;
domainEvents.add(new OrderShippedEvent(this.id, Instant.now()));
}
public List<DomainEvent> getDomainEvents() {
return List.copyOf(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
Finally, an event handler, often in the application layer, listens and reacts.
@Component
public class OrderShippedEventHandler {
private final NotificationService notificationService;
private final LogisticsService logisticsService;
@EventListener
public void handleOrderShipped(OrderShippedEvent event) {
// These actions are decoupled from the main "ship" transaction.
notificationService.sendShippingConfirmation(event.getOrderId());
logisticsService.updateTracking(event.getOrderId());
}
}
This was a game-changer for me. It keeps my services focused on their primary task. New side effects can be added by creating new handlers, without modifying the original shipping code. It reduces coupling and makes the system easier to extend.
One of the biggest mistakes I made early on was exposing my JPA entities directly through my REST API. This couples your internal model to your external contract. If you add a new field to the database entity, it suddenly appears in your API. If you change a relationship, your API breaks.
Instead, I create Data Transfer Objects (DTOs) that are tailor-made for each API endpoint.
// Request DTO for the API
public record PlaceOrderRequest(
@NotBlank String customerId,
@NotEmpty List<OrderItemRequest> items,
@NotBlank String paymentToken
) {}
// Response DTO for the API
public record OrderSummaryResponse(
String orderId,
String customerName,
BigDecimal total,
String status,
LocalDate orderedOn
) {}
I also use this idea for database queries. Instead of fetching entire entity graphs and converting them, I often write queries that return exactly the data I need, already shaped as a DTO.
@Repository
public interface OrderJpaEntityRepository extends JpaRepository<OrderJpaEntity, Long> {
// This query returns a simple, flat DTO, not a full entity graph.
@Query("SELECT new com.example.application.dto.OrderSummaryDTO(o.id, c.name, o.totalAmount, o.status) " +
"FROM OrderJpaEntity o JOIN o.customerEntity c " +
"WHERE o.createdAt >= :sinceDate")
List<OrderSummaryDTO> findOrderSummariesSince(@Param("sinceDate") LocalDate sinceDate);
}
This technique, called Projection, improves performance by fetching only necessary columns and avoids the complexity of lazy loading issues.
Validation is crucial, but it exists at different levels. Simple, syntactic validation belongs at the boundaries: “Is this email field formatted correctly?” “Is this required field present?” I use Bean Validation (@NotNull, @Email, @Size) on my request DTOs, as shown earlier.
However, more complex validation is often a business rule: “Can this user discount this order?” “Is this product compatible with the customer’s subscription plan?” These rules belong inside the domain entities or domain services, not in the controllers. Keeping this distinction clear stops validation logic from leaking into the wrong layers.
For complex reporting or dashboard features, the read operations can become very different from the write operations. The Command Query Responsibility Segregation (CQRS) pattern suggests we can separate them entirely. I don’t always need a full CQRS system with separate databases, but I often create dedicated query services for reads.
These services bypass the domain model completely. They talk directly to the database using efficient, purpose-built SQL or JPQL queries and return DTOs perfectly shaped for a screen or API response.
@Component
public class OrderQueryService {
private final JdbcTemplate jdbcTemplate;
public PagedResult<OrderDashboardView> getDashboardOrders(int page, int size) {
String countSql = "SELECT COUNT(*) FROM orders WHERE status = 'ACTIVE'";
int total = jdbcTemplate.queryForObject(countSql, Integer.class);
String dataSql = """
SELECT o.id, o.order_number, c.name as customer_name,
o.total_amount, o.created_at, COUNT(oi.id) as item_count
FROM orders o
JOIN customers c ON o.customer_id = c.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.status = 'ACTIVE'
GROUP BY o.id, c.name
ORDER BY o.created_at DESC
LIMIT ? OFFSET ?
""";
List<OrderDashboardView> orders = jdbcTemplate.query(dataSql,
(rs, rowNum) -> new OrderDashboardView(
rs.getString("id"),
rs.getString("order_number"),
rs.getString("customer_name"),
rs.getBigDecimal("total_amount"),
rs.getTimestamp("created_at").toLocalDateTime(),
rs.getInt("item_count")
), size, page * size);
return new PagedResult<>(orders, page, size, total);
}
}
This is incredibly efficient. The query is optimized for the view, and I’m not dragging a complex domain object graph into memory just to display a list.
The ultimate payoff of these techniques is testability. Because I’ve separated concerns, I can test each part in isolation.
My domain entities are plain Java, so their tests are fast and simple JUnit tests.
class OrderTest {
@Test
void addingItemIncreasesTotalAmount() {
Product book = new Product(new ProductId("P1"), "Clean Code", new Money("42.50"));
Order order = new Order(new CustomerId("C1"));
order.addItem(book, 2);
assertEquals(new Money("85.00"), order.getTotalAmount());
assertEquals(1, order.getItems().size());
}
@Test
void cannotAddItemWithZeroQuantity() {
Product book = new Product(new ProductId("P1"), "Clean Code", new Money("42.50"));
Order order = new Order(new CustomerId("C1"));
assertThrows(InvalidQuantityException.class, () -> {
order.addItem(book, 0);
});
}
}
My application services can be tested by mocking the port interfaces (like OrderRepository). I use @SpringBootTest only for integration tests that verify the whole flow from controller to database. This testing pyramid—lots of fast unit tests, fewer integration tests—makes my test suite reliable and quick to run.
These ten techniques form a practical approach to building Spring Boot applications that last. The goal isn’t dogmatic purity, but creating a system where the most valuable part—your business logic—is protected, clear, and easy to change. Spring Boot handles the plumbing, wiring, and infrastructure complexity. Your code defines what your business actually does. This separation gives you the freedom to adapt, scale, and maintain your application long after the initial excitement of the first release has faded. It turns a quick prototype into a robust, professional system.