What a Spring Controller Should and Shouldn't Do — A Practical Boundary Guide
by Eric Hanson, Backend Developer at Clean Systems Consulting
What a controller is for
A controller has one job: translate between HTTP and your application's domain operations. It receives an HTTP request, converts it to something the service layer understands, calls the service, and converts the result back into an HTTP response.
That's it. A controller that does more than this is a controller with mixed responsibilities — and the mixing is exactly what makes controllers hard to test and maintain.
The clean controller action:
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request,
@AuthenticationPrincipal UserDetails user) {
Order order = orderService.createOrder(
user.getUsername(),
request.items(),
request.paymentMethodId()
);
return ResponseEntity
.created(URI.create("/api/v1/orders/" + order.id()))
.body(OrderResponse.from(order));
}
}
Three responsibilities:
- Extract what the service needs from the HTTP request (
user.getUsername(),request.items(),request.paymentMethodId()) - Call the service
- Convert the result to an HTTP response (
ResponseEntity.created(...),OrderResponse.from(order))
No business logic. No query construction. No conditional branching beyond the success/failure split from the service result.
What doesn't belong in a controller
Business rules. Any conditional logic that implements application behavior — not HTTP behavior — belongs in the service layer:
// Wrong — business rule in controller
@PostMapping("/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable String id) {
Order order = orderRepository.findById(id).orElseThrow();
if (order.getStatus().equals("SHIPPED")) {
return ResponseEntity.badRequest().build(); // business rule
}
if (order.getCreatedAt().isBefore(LocalDateTime.now().minusHours(24))) {
return ResponseEntity.badRequest().build(); // business rule
}
order.setStatus("CANCELLED");
orderRepository.save(order);
return ResponseEntity.ok().build();
}
// Correct — controller delegates, service enforces rules
@PostMapping("/{id}/cancel")
public ResponseEntity<Void> cancelOrder(@PathVariable String id,
@AuthenticationPrincipal UserDetails user) {
orderService.cancelOrder(id, user.getUsername());
return ResponseEntity.ok().build();
}
The service throws OrderCancellationNotAllowedException if the order can't be cancelled. The controller handles that via @ExceptionHandler. The rules live in one place — the service — regardless of how many endpoints might trigger the same operation.
Database queries. A controller that injects a repository directly is a controller doing the service layer's job:
// Wrong — controller does query construction
@GetMapping
public List<OrderSummary> getOrders(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@AuthenticationPrincipal UserDetails user) {
if (status != null) {
return orderRepository.findByUserIdAndStatus(user.getUsername(), status,
PageRequest.of(page, 20));
}
return orderRepository.findByUserId(user.getUsername(),
PageRequest.of(page, 20));
}
// Correct — controller delegates query decisions
@GetMapping
public Page<OrderSummary> getOrders(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@AuthenticationPrincipal UserDetails user) {
return orderService.findOrders(user.getUsername(), status, page);
}
Authorization logic beyond annotation. Spring Security annotations (@PreAuthorize, @Secured) handle method-level authorization correctly. Custom authorization logic in controller method bodies is business logic in the wrong layer:
// Wrong — authorization logic in controller body
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable String id,
@AuthenticationPrincipal UserDetails user) {
Order order = orderService.findById(id);
if (!order.getUserId().equals(user.getUsername()) && !user.getAuthorities()
.contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
throw new AccessDeniedException("Not authorized");
}
return OrderResponse.from(order);
}
// Correct — Spring Security annotation
@GetMapping("/{id}")
@PreAuthorize("@orderSecurityService.canAccess(#id, authentication)")
public OrderResponse getOrder(@PathVariable String id) {
return OrderResponse.from(orderService.findById(id));
}
The @PreAuthorize expression delegates to a @Service that encapsulates the authorization rule. The controller method body stays clean.
Request and response objects
Requests and responses are the controller layer's data contracts. They should be simple, serializable records or POJOs with validation annotations:
// Request — what the HTTP client sends
public record CreateOrderRequest(
@NotEmpty List<@Valid LineItemRequest> items,
@NotBlank String paymentMethodId,
String couponCode // optional — null is valid
) {}
public record LineItemRequest(
@NotBlank String productId,
@Positive int quantity
) {}
// Response — what the HTTP client receives
public record OrderResponse(
String id,
String status,
long totalInCents,
String currency,
Instant createdAt,
List<LineItemResponse> items
) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.id(),
order.status().name(),
order.total().amountInCents(),
order.total().currency().code(),
order.createdAt(),
order.items().stream().map(LineItemResponse::from).toList()
);
}
}
The from() factory method on the response object keeps the mapping logic out of the controller and makes it testable in isolation.
Request objects should not be passed directly to the service layer. The service layer's method signature is defined by the domain operation, not by the HTTP request shape. A CreateOrderRequest that matches the HTTP body may not match the service method's parameters — and that's fine. The controller's job is to extract what the service needs:
// Wrong — passes HTTP request object to service
orderService.createOrder(request); // service now knows about HTTP request shape
// Correct — extracts what the service needs
orderService.createOrder(userId, request.items(), request.paymentMethodId());
Validation placement
Bean Validation (@Valid, @NotNull, @NotBlank) on request objects handles input validation — verifying that the HTTP client sent well-formed data. This belongs in the controller layer:
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) { // @Valid triggers Bean Validation
...
}
Business validation — verifying that the operation is permitted given current application state — belongs in the service layer:
// Input validation — controller layer
@NotBlank String productId;
@Positive int quantity;
// Business validation — service layer
public Order createOrder(String userId, List<LineItemRequest> items, String paymentMethodId) {
User user = userRepository.findById(userId).orElseThrow();
if (!user.isActive()) throw new UserNotActiveException(userId);
// ...
}
Input validation errors return 400 Bad Request. Business validation errors return application-specific status codes (422 Unprocessable Entity, 409 Conflict, 403 Forbidden). They're different categories of failure and belong in different layers.
Exception handling — one place, not per controller
Handling exceptions in every controller method produces duplicated try-catch blocks and inconsistent error responses. @ControllerAdvice centralizes exception handling:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("not_found", ex.getMessage()));
}
@ExceptionHandler(OrderCancellationNotAllowedException.class)
public ResponseEntity<ErrorResponse> handleBusinessRuleViolation(
OrderCancellationNotAllowedException ex) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(new ErrorResponse("cancellation_not_allowed", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
List<FieldError> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
return ResponseEntity.badRequest()
.body(new ValidationErrorResponse("validation_failed", errors));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex, HttpServletRequest req) {
log.error("Unexpected error handling {}", req.getRequestURI(), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("internal_error", "An unexpected error occurred"));
}
}
The @ControllerAdvice maps exception types to HTTP responses in one place. Controller methods throw exceptions freely — they never catch them. The response shape is consistent across all endpoints because it's defined in one place.
Testing the boundary
A clean controller is testable with MockMvc without starting a full application context:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
void createOrder_returns201_withOrderId() throws Exception {
given(orderService.createOrder(any(), any(), any()))
.willReturn(new Order("ord-123", ...));
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"items": [{"productId": "p-1", "quantity": 2}],
"paymentMethodId": "pm-visa"}
""")
.with(user("user@example.com")))
.andExpect(status().isCreated())
.andExpect(header().string("Location", containsString("/api/v1/orders/ord-123")))
.andExpect(jsonPath("$.id").value("ord-123"));
}
@Test
void createOrder_returns400_whenItemsEmpty() throws Exception {
mockMvc.perform(post("/api/v1/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"items": [], "paymentMethodId": "pm-visa"}""")
.with(user("user@example.com")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"));
}
}
@WebMvcTest loads only the web layer — no service beans, no repositories, no database. The @MockBean for OrderService makes the controller testable in isolation. Tests run in milliseconds and verify the HTTP contract: status codes, headers, response shapes, and validation behavior.
A controller that can't be tested this way — because it has too many dependencies, because it calls repositories directly, because it embeds business logic — is a controller that has accumulated responsibilities it shouldn't have.