Spring MVC & Building REST APIs

Difficulty

Both mark a class as a Spring MVC controller, discovered via component scanning, but they differ in how a handler method's return value is treated:

@Controller — return values are treated as logical view names by default, resolved by a ViewResolver into an actual view (e.g., a Thymeleaf/JSP template) to render:

@Controller
class HomeController {
    @GetMapping("/home")
    String home(Model model) {
        model.addAttribute("message", "Welcome");
        return "home"; // resolved to a "home" view template, not returned as raw text
    }
}

To return raw data (e.g., JSON) from an @Controller, you must explicitly annotate the method with @ResponseBody:

@Controller
class ApiController {
    @GetMapping("/api/status")
    @ResponseBody
    Map<String, String> status() { return Map.of("status", "ok"); } // written directly as JSON
}

@RestController is a meta-annotation combining @Controller and @ResponseBody at the class level — every handler method's return value is automatically serialized (typically to JSON via Jackson) and written straight to the response body, with no view resolution involved:

@RestController
class ApiController {
    @GetMapping("/api/status")
    Map<String, String> status() { return Map.of("status", "ok"); } // JSON body, no @ResponseBody needed
}

Rule of thumb: use @RestController for REST/JSON APIs (the vast majority of modern Spring Boot backends); use @Controller specifically when a controller renders server-side views, or has a genuine mix of both view-rendering and data-returning endpoints.

The DispatcherServlet is Spring MVC's single front controller — every HTTP request for the application passes through it first, and it orchestrates all the subsequent steps:

  1. HandlerMapping — the DispatcherServlet asks the configured HandlerMapping(s) which handler (typically an @RequestMapping-annotated controller method) matches this request's URL, HTTP method, headers, etc.

  2. HandlerInterceptors — before invoking the handler, any registered interceptors' preHandle() methods run (can short-circuit the request, e.g., for auth checks).

  3. Argument resolution — the framework resolves each handler method parameter (@PathVariable, @RequestParam, @RequestBody, ...) via the appropriate HandlerMethodArgumentResolver, converting raw request data into typed Java objects.

  4. Handler invocation — the actual controller method executes with its resolved arguments.

  5. Return value handling:

    • For an @RestController (or a method with @ResponseBody), the return value is passed to an appropriate HttpMessageConverter (Jackson's MappingJackson2HttpMessageConverter for JSON, by default), which serializes it directly to the HTTP response body.
    • For a plain @Controller returning a view name, the DispatcherServlet consults a ViewResolver to locate the actual View (a Thymeleaf template, JSP, etc.) and renders it, typically with a Model populated by the controller.
  6. Interceptors' postHandle()/afterCompletion() run as the response is finalized.

  7. Exception handling — if any step throws, a HandlerExceptionResolver (which is what powers @ExceptionHandler/@ControllerAdvice) gets a chance to convert the exception into an appropriate error response instead of an unhandled failure.

Request → DispatcherServlet → HandlerMapping (find handler)
        → HandlerInterceptor.preHandle()
        → Controller method invocation (args resolved)
        → HttpMessageConverter (JSON) or ViewResolver (view rendering)
        → HandlerInterceptor.postHandle() / afterCompletion()
        → Response

Understanding this pipeline is what makes it clear why a HandlerInterceptor runs differently from a servlet Filter (interceptors operate inside Spring MVC's dispatch, with access to the resolved handler; filters operate at the raw servlet level, before Spring MVC is even involved) — a common, related interview follow-up.

Related Resources

These four annotations cover how data flows into and out of a Spring MVC handler method:

@RequestBody — deserializes the HTTP request body into a Java object, using a registered HttpMessageConverter (Jackson, for JSON):

@PostMapping("/orders")
Order createOrder(@RequestBody OrderRequest request) { ... } // JSON body -> OrderRequest object

@ResponseBody — serializes the method's return value directly into the HTTP response body, bypassing view resolution (automatically implied for every method when the class is @RestController):

@GetMapping("/orders/{id}")
@ResponseBody
Order getOrder(@PathVariable Long id) { ... } // Order object -> JSON response body

@PathVariable — binds a value from a URI template variable (a placeholder segment of the mapped path) to a parameter:

@GetMapping("/orders/{id}")
Order getOrder(@PathVariable Long id) { ... } // GET /orders/42 -> id = 42

@RequestParam — binds a value from a query string parameter (or a form field in a form submission) to a parameter, optionally with a default and optionality:

@GetMapping("/orders")
List<Order> listOrders(@RequestParam(defaultValue = "10") int limit,
                        @RequestParam(required = false) String status) { ... }
// GET /orders?limit=20&status=SHIPPED

Quick way to keep them straight: @PathVariable is part of the URL's path (/orders/{id}); @RequestParam is part of the URL's query string (?limit=20) or form data; @RequestBody is the request's payload; @ResponseBody is the method's return value going out as the response payload.

Spring Boot integrates the Bean Validation standard (Jakarta Validation API, typically implemented by Hibernate Validator, pulled in automatically by spring-boot-starter-validation) for declarative, annotation-based input validation:

1. Annotate the request DTO's fields:

record CreateOrderRequest(
    @NotBlank String customerId,
    @Min(1) int quantity,
    @Email String contactEmail
) { }

2. Add @Valid in the controller to trigger validation on that parameter:

@RestController
class OrderController {
    @PostMapping("/orders")
    Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
        // if we reach this line, request has already passed all validation rules
    }
}

If validation fails, Spring MVC throws MethodArgumentNotValidException before the handler method body ever executes — the controller code never has to manually check if (quantity < 1) ....

3. Handle validation failures globally, converting them into a consistent, structured error response instead of a generic 500:

@RestControllerAdvice
class ValidationExceptionHandler {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
            .forEach(err -> errors.put(err.getField(), err.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors); // 400, with field-level error details
    }
}

Common annotations: @NotNull/@NotBlank/@NotEmpty (subtly different: @NotNull allows empty strings, @NotBlank doesn't allow blank/whitespace-only strings, @NotEmpty disallows empty-but-not-null collections/strings), @Size(min=, max=), @Min/@Max, @Email, @Pattern(regexp=).

Nested object validation: if a DTO contains another validated object as a field, add @Valid on that nested field too, or the nested object's own constraints won't be checked — @Valid on the controller parameter alone only validates that top-level object's direct fields plus explicitly @Valid-annotated nested fields.

@Validated vs @Valid: @Valid is the standard JSR-380 annotation; @Validated is a Spring-specific variant that additionally supports validation groups and enables method-level validation (validating parameters of a plain @Service method, not just @RequestBody controller arguments).

Related Resources

Rather than wrapping every controller method in a try/catch, Spring MVC lets you declare exception-handling logic separately, and have it apply automatically wherever a matching exception is thrown.

@ExceptionHandler on a method inside a single controller — handles exceptions thrown by that controller's handler methods only:

@RestController
class OrderController {
    @GetMapping("/orders/{id}")
    Order getOrder(@PathVariable Long id) {
        return repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }

    @ExceptionHandler(OrderNotFoundException.class)
    ResponseEntity<String> handleNotFound(OrderNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
    }
}

@RestControllerAdvice (or @ControllerAdvice + @ResponseBody) makes @ExceptionHandler methods apply globally, across every controller in the application — the standard way to centralize consistent error formatting instead of duplicating handling logic per-controller:

@RestControllerAdvice
class GlobalExceptionHandler {
    @ExceptionHandler(OrderNotFoundException.class)
    ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("ORDER_NOT_FOUND", ex.getMessage()));
    }

    @ExceptionHandler(Exception.class) // catch-all fallback
    ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
    }
}

Resolution order: Spring matches the most specific applicable @ExceptionHandler — a handler for the exact thrown exception type wins over a handler for one of its superclasses, and a handler defined on the specific controller itself (via a local @ExceptionHandler) takes precedence over a matching global @ControllerAdvice handler.

Practical benefits of centralizing this in a @RestControllerAdvice:

  • Consistent error response shape across the entire API (a code/message/timestamp structure, for example), which API consumers can rely on uniformly.
  • Avoids leaking internal exception details/stack traces to clients — the handler controls exactly what's exposed.
  • Keeps controller methods focused on their actual logic, free of repetitive try/catch boilerplate.

Spring Boot also ships a default fallback (ErrorController/BasicErrorController) that produces a generic JSON error body for any exception not otherwise handled — a @RestControllerAdvice typically exists specifically to produce something more specific and informative than that generic default.