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:

  1. Extract what the service needs from the HTTP request (user.getUsername(), request.items(), request.paymentMethodId())
  2. Call the service
  3. 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.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

What Actually Happens When Spring Boot Starts Up

Spring Boot startup involves auto-configuration, bean registration, context refresh, and lifecycle callbacks — in a specific order that determines when your code runs and why some startup bugs are hard to diagnose.

Read more

Why Some Software Projects Are Doomed From the Start

“We know this won’t work… but we have to do it anyway.” Sometimes, failure isn’t accidental — it’s scheduled.

Read more

Why Contractors Shouldn’t Be Forced Into Client Offices

“Wait, I have to come to the office every day… as a contractor?” That moment when a flexible contract suddenly feels like a full-time job—with none of the benefits.

Read more

Why Backend Systems Fail at Scale

“It worked perfectly… until we got users.” Scale doesn’t break systems — it reveals what was already fragile.

Read more