5 Tips to Keep Your Spring Service Clean and High‑Quality
This article presents five practical patterns—single‑use‑case classes, a lightweight Facade, self‑validating input records, domain‑event side‑effects, and ports‑and‑adapters—that transform monolithic Spring @Service classes into modular, readable, testable, and transaction‑safe components, improving maintainability and extensibility.
Environment: Spring Boot 3.5.0.
1. Introduction
Junior developers often treat a Service class as a catch‑all container for every operation in a domain, while senior developers view a Service as an orchestrator, not a garbage bin. A monolithic Service quickly becomes hard to read, test, and evolve.
2. Pattern 1 – One Use‑Case per Class
Problem: Adding new operations to the same Service forces unrelated methods to share the same class and injected dependencies, risking accidental breakage.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final TaxService taxService;
private final EmailService emailService;
public Order createOrder(CreateOrderRequest req) { /* ... */ }
public void cancelOrder(Long orderId) { /* ... */ }
public void generateInvoice(Long orderId) { /* ... */ }
// ... other unrelated methods
}Advanced approach: Create a dedicated class for each use‑case, exposing a single execute method and declaring only the exact dependencies it needs.
@Component
public class CreateOrderUseCase {
private final OrderRepository orderRepository;
private final CustomerRepository customerRepository;
public Order execute(CreateOrderInput input) {
Customer customer = customerRepository.findById(input.customerId())
.orElseThrow(() -> new CustomerNotFoundException(input.customerId()));
Order order = new Order(customer, input.items());
return orderRepository.save(order);
}
}Clear and readable: New developers can understand the whole flow by reading a single class.
Pure dependencies: Adding a new operation does not pollute existing use‑case constructors.
Simplified testing: Only two mocks are needed instead of fourteen, reducing test code from dozens of lines to a handful.
Safe extension: Adding a new use‑case creates a new file without touching existing code.
3. Pattern 2 – Lightweight Facade
Problem: After extracting use‑case classes, some developers move orchestration logic into the Controller, causing the Controller to handle business sequencing, discounts, events, and response assembly.
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest req) {
Order order = createOrderUseCase.execute(req.toInput());
if (req.getDiscountCode() != null) {
applyDiscountUseCase.execute(order.getId(), req.getDiscountCode());
}
eventPublisher.publishEvent(new OrderPlacedEvent(order));
return ResponseEntity.status(201).body(OrderResponse.from(order));
}Advanced approach: Introduce a thin @Service Facade that owns the transaction boundary and orchestrates use‑cases, leaving the Controller to handle only HTTP concerns.
@Service
@Transactional
public class OrderFacade {
private final CreateOrderUseCase createOrderUseCase;
private final ApplyDiscountUseCase applyDiscountUseCase;
private final ApplicationEventPublisher eventPublisher;
public Order placeOrder(CreateOrderInput input, String discountCode) {
Order order = createOrderUseCase.execute(input);
if (discountCode != null) {
applyDiscountUseCase.execute(new ApplyDiscountInput(order.getId(), discountCode));
}
eventPublisher.publishEvent(new OrderPlacedEvent(order));
return order;
}
}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest req) {
Order order = orderFacade.placeOrder(req.toInput(), req.getDiscountCode());
return ResponseEntity.status(201).body(OrderResponse.from(order));
}Unified transaction: @Transactional on the Facade guarantees both use‑cases commit together.
Multi‑endpoint reuse: The Facade can be called from controllers, message listeners, or scheduled jobs.
Centralized logic: Business ordering changes in one place.
Easy controller testing: Controllers can be tested with MockMvc without mocking business logic.
4. Pattern 3 – Self‑Validating Input
Problem: Validation logic is scattered across Controller annotations, use‑case code, domain objects, and database constraints, leading to four inconsistent failure handling paths.
public record CreateOrderInput(
@NotNull(message = "Customer ID is required") Long customerId,
@NotEmpty(message = "Order must contain at least one item") List<OrderItemInput> items) {}
@Component
@Validated
public class CreateOrderUseCase {
public Order execute(@Valid CreateOrderInput input) {
Customer customer = customerRepository.findById(input.customerId())
.orElseThrow(() -> new CustomerNotFoundException(input.customerId()));
return orderRepository.save(new Order(customer, input.items()));
}
}All invalid inputs are rejected before the method body runs, eliminating defensive checks.
Pre‑interception: @Validated + @Valid stop invalid data at the method entry.
Explicit contract: The Record itself defines the validation contract.
Unified exception handling: A global @ExceptionHandler(ConstraintViolationException.class) returns a consistent error response.
Easy extension: Adding a rule only requires a new annotation on the Record.
5. Pattern 4 – Domain Events for Side Effects
Problem: A use‑case that performs core business logic also directly invokes email, audit, and inventory services, causing the class to bloat whenever a new side effect is required.
@Transactional
public void execute(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.cancel();
orderRepository.save(order);
emailService.sendCancellation(order);
auditService.log("CANCELLED", orderId);
inventoryService.release(order);
}Advanced approach: Publish a domain event after the core operation; separate listeners handle each side effect.
@Component
@Transactional
public class CancelOrderUseCase {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
public void execute(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.cancel();
orderRepository.save(order);
eventPublisher.publishEvent(new OrderCancelledEvent(order));
}
}
@Component
public class OrderCancellationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCancelled(OrderCancelledEvent event) {
emailService.sendCancellation(event.order());
auditService.log("ORDER_CANCELLED", event.order().getId());
}
}Transactional safety: @TransactionalEventListener(AFTER_COMMIT) ensures side effects run only after a successful commit.
Decoupled extension: Adding a new side effect only requires a new listener class.
Simplified testing: Tests mock only the ApplicationEventPublisher instead of all external services.
Easy removal: Deleting a listener removes the side effect without touching core logic.
6. Pattern 5 – Ports and Adapters
Problem: Directly injecting JpaOrderRepository or KafkaTemplate couples the use‑case to specific infrastructure, making technology swaps painful.
@Component
public class CreateOrderUseCase {
private final JpaOrderRepository orderRepository; // infrastructure leak
private final KafkaTemplate<String, OrderEvent> kafka; // infrastructure leak
}Advanced approach: Define a domain‑level interface (port) that the use‑case depends on; provide an infrastructure implementation (adapter) in a separate package.
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(Long id);
}
@Repository
public class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderRepository jpa;
@Override public Order save(Order order) { return jpa.save(OrderEntity.from(order)).toDomain(); }
@Override public Optional<Order> findById(Long id) { return jpa.findById(id).map(OrderEntity::toDomain); }
}
@Component
public class CreateOrderUseCase {
private final OrderRepository orderRepository; // depends only on the port
}Technology‑agnostic core: Switching from JPA to JDBC only requires a new adapter.
Correct dependency direction: Infrastructure depends on the domain, not vice‑versa.
Composable patterns: This repository port can be combined with the other patterns above.
7. Conclusion
Applying these five patterns—single‑use‑case classes, a Facade, self‑validating input records, domain‑event side‑effects, and ports‑and‑adapters—turns a sprawling @Service into a clean, modular, and easily testable codebase where new features are added by creating new files rather than modifying existing ones.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Spring Full-Stack Practical Cases
Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
