Spring MVC

Spring MVC

MVC Architecture

Spring MVC follows the classic Front Controller pattern layered onto MVC: a single entry point (DispatcherServlet) receives every request and delegates to the appropriate handler, rather than each URL mapping to its own servlet.

Client → DispatcherServlet → HandlerMapping → Controller → Model
                                                    ↓
Client ← View (rendered) ← ViewResolver ← ModelAndView
Layer Responsibility
Model Domain/business data — POJOs, DTOs, service-layer results
View Rendering (Thymeleaf/JSP for server-rendered, or just serialized JSON for REST)
Controller Receives request, invokes business logic, populates model, selects view (or returns body directly for REST)

Architect framing: for REST APIs (the dominant case in modern microservices), the “View” is effectively the HTTP response body — @RestController short-circuits the View-resolution path entirely via HttpMessageConverter (Jackson) writing directly to the response. Understanding this distinction — and that classic server-rendered MVC and REST share the same dispatcher infrastructure — is a common point of confusion interviewers probe.


DispatcherServlet

The DispatcherServlet is the central front controller — a single servlet registered against a URL pattern (typically / in Spring Boot) that orchestrates the entire request lifecycle.

Boot auto-configuration: DispatcherServletAutoConfiguration registers it automatically with sensible defaults; you rarely configure it manually unless customizing multipart resolution, async support timeouts, or registering a second DispatcherServlet for a separate URL namespace (rare, multi-app-in-one-jar scenarios).

@Bean
public ServletRegistrationBean<DispatcherServlet> dispatcherServlet(DispatcherServlet servlet) {
    ServletRegistrationBean<DispatcherServlet> registration =
        new ServletRegistrationBean<>(servlet, "/api/*");
    registration.setLoadOnStartup(1);
    return registration;
}

What it actually does per request (the “lifecycle” interviewers want to hear in sequence):

  1. Receives HttpServletRequest
  2. Consults HandlerMapping to find the matching handler (controller method) + any HandlerInterceptors
  3. Wraps it in a HandlerExecutionChain
  4. Invokes preHandle() on each interceptor
  5. Delegates to HandlerAdapter to actually invoke the controller method (handles argument resolution — @RequestBody, @PathVariable, etc.)
  6. Receives ModelAndView (or, for @RestController, the return value goes straight to an HttpMessageConverter)
  7. Invokes postHandle() on interceptors
  8. If a view name was returned, consults ViewResolver to resolve and render it
  9. Invokes afterCompletion() on interceptors
  10. If an exception occurred anywhere, hands off to HandlerExceptionResolver chain (this is where @ControllerAdvice plugs in)

HandlerMapping, ViewResolver

HandlerMapping — maps an incoming request to a handler (controller method).

Implementation Matches based on
RequestMappingHandlerMapping @RequestMapping/@GetMapping etc. annotations — the one used in virtually all modern apps
BeanNameUrlHandlerMapping Bean name matching URL (legacy)
SimpleUrlHandlerMapping Explicit URL pattern → handler bean map (legacy XML-config style)

Multiple HandlerMappings can coexist; Spring iterates them in Ordered priority until one returns a non-null HandlerExecutionChain.

ViewResolver — resolves a logical view name (e.g., "orderConfirmation") returned by a controller into an actual renderable View.

Implementation Resolves to
ThymeleafViewResolver .html templates under templates/
InternalResourceViewResolver JSP files
ContentNegotiatingViewResolver Delegates based on Accept header (JSON vs HTML)

Architect-level point: for pure REST services, ViewResolver is largely irrelevant — @RestController (@Controller + @ResponseBody) bypasses it entirely, since HttpMessageConverters handle serialization. Knowing when this layer is skipped is often the actual depth interviewers are probing for, not the resolver mechanics themselves.


Controllers & REST Endpoints

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return ResponseEntity.ok(orderService.findById(id));
    }

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody OrderRequest request) {
        OrderResponse created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/v1/orders/" + created.id()))
                .body(created);
    }

    @GetMapping
    public Page<OrderResponse> list(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        return orderService.list(PageRequest.of(page, size));
    }
}

Key argument resolvers architects should know cold:

Annotation Source
@PathVariable URI template segment
@RequestParam Query string parameter
@RequestBody Deserialized request body (Jackson)
@RequestHeader HTTP header
@ModelAttribute Form-bound or pre-populated model object

ResponseEntity<T> vs returning T directly: use ResponseEntity when you need explicit control over status code, headers, or location URI (creation, conditional responses) — returning the bare body type defaults to 200 OK and gives no header control. A frequent architect-review correction is seeing 201 Created semantics implemented by just returning the body with an implicit 200 status.


Request/Response Lifecycle

Full path for a typical REST call, tying the earlier pieces together:

  1. Request hits the embedded server (Tomcat/Netty) → servlet container
  2. Filters (servlet-spec, container-level) run first — FilterChain.doFilter()
  3. DispatcherServlet.doService() invoked
  4. HandlerMapping resolves handler + interceptor chain
  5. Interceptor preHandle()
  6. HandlerAdapter resolves method arguments, invokes controller method
  7. If @Valid is present, JSR-303 validation runs before the method body executes; failures short-circuit to exception handling
  8. Controller returns → HttpMessageConverter serializes return value (REST) or ModelAndView is built (MVC)
  9. Interceptor postHandle()
  10. View rendering (if applicable) or direct body write
  11. Interceptor afterCompletion()
  12. Response flows back out through the Filter chain (in reverse)
  13. Container writes response to the client socket

If an exception is thrown anywhere from step 6 onward, control jumps to the HandlerExceptionResolver chain (@ControllerAdvice) — interceptor afterCompletion() still fires even on exception, but postHandle() does not.


Filters vs Interceptors

A very common architect/senior interview question — the distinction matters for where cross-cutting concerns belong.

Aspect Filter Interceptor
Spec Servlet API (javax.servlet.Filter / jakarta.servlet.Filter) Spring MVC (HandlerInterceptor)
Scope Wraps the entire servlet container request, before DispatcherServlet even runs Wraps only the handler execution, inside DispatcherServlet
Access to handler info No — doesn’t know which controller method will run Yes — has access to the resolved HandlerMethod, annotations, etc.
Spring context awareness Limited (works, but not idiomatic for Spring-specific logic) Full Spring bean access, naturally
Typical use Authentication/authorization at the edge, CORS, request/response logging, compression, encoding Logging tied to handler metadata, @RequireRole-style annotation checks, request timing scoped to MVC, locale/theme resolution
Multiple instances Chained via FilterChain Chained via HandlerExecutionChain, ordered
// Filter — servlet-level, runs before Spring even sees the request
@Component
public class CorrelationIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String correlationId = ((HttpServletRequest) req).getHeader("X-Correlation-Id");
        MDC.put("correlationId", correlationId != null ? correlationId : UUID.randomUUID().toString());
        try {
            chain.doFilter(req, res);
        } finally {
            MDC.clear();
        }
    }
}

// Interceptor — Spring MVC-aware, knows the target handler
@Component
public class AuditInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        if (handler instanceof HandlerMethod hm && hm.hasMethodAnnotation(Audited.class)) {
            // log audited action
        }
        return true; // false short-circuits the chain
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuditInterceptor()).addPathPatterns("/api/**");
    }
}

Architect rule of thumb: anything that should apply regardless of whether the request even reaches a Spring controller (security perimeter, CORS, raw logging, gzip) → Filter. Anything that needs to know which controller/annotation is being invoked → Interceptor.


Exception Handling (@ControllerAdvice)

Centralizes exception-to-response mapping instead of try/catch scattered across every controller method.

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        String details = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_FAILED", details));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
        log.error("Unhandled exception", ex);
        return ResponseEntity.internalServerError()
                .body(new ErrorResponse("INTERNAL_ERROR", "Unexpected error occurred"));
    }
}
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody (returns serialized body directly — standard for REST).
  • Handler methods are matched by most specific exception type first — order your handlers from specific to generic; a catch-all Exception handler should always exist as a safety net to avoid leaking stack traces to clients.
  • Can scope to specific packages/controllers via @ControllerAdvice(basePackages = "...") or assignableTypes — useful in modular monoliths where different modules need different error contracts.

Architect-level nuance: @ControllerAdvice only intercepts exceptions thrown within the DispatcherServlet’s handling flow — it cannot catch exceptions thrown in Filters (since filters run before the dispatcher) or in async/separate-thread code without explicit propagation. For filter-level errors, you need a dedicated error-handling filter or rely on the container’s default error page mechanism (ErrorController).


Validation (JSR-303 / Bean Validation)

Declarative constraint annotations on DTOs, enforced automatically when combined with @Valid/@Validated.

public record OrderRequest(
    @NotNull(message = "Customer ID is required")
    Long customerId,

    @NotEmpty(message = "Order must contain at least one item")
    @Size(max = 50, message = "Cannot exceed 50 items per order")
    List<@Valid OrderItemRequest> items,

    @Email
    String notificationEmail,

    @Positive
    @DecimalMax(value = "1000000.00")
    BigDecimal totalAmount
) {}
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody OrderRequest request) {
    // if validation fails, MethodArgumentNotValidException is thrown
    // before this method body ever executes
}

Common annotations: @NotNull, @NotEmpty, @NotBlank, @Size, @Min/@Max, @Positive/@Negative, @Email, @Pattern, @Past/@Future, @Valid (cascades validation into nested objects/collections).

@Valid vs @Validated:

  • @Valid (javax.validation/jakarta.validation) — standard JSR-303, supports cascading into nested objects, no group support.
  • @Validated (Spring’s own) — adds validation groups, letting you apply different constraint subsets for different operations (e.g., OnCreate requires an ID to be absent, OnUpdate requires it present).
public interface OnCreate {}
public interface OnUpdate {}

public record UserRequest(
    @Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id,
    @NotBlank String name
) {}

@PostMapping
public void create(@Validated(OnCreate.class) @RequestBody UserRequest req) { }

Custom validators for cross-field or business-rule constraints that built-ins can’t express:

@Constraint(validatedBy = DateRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDateRange { String message() default "End date must be after start date"; }

Architect-level talking point: bean validation belongs at the API boundary (DTO layer) to reject malformed input cheaply and with clear client-facing error messages — it is not a substitute for domain/business-rule validation in the service layer (e.g., “customer has sufficient credit limit”), which depends on state and should live in the domain model or service, not as an annotation. Conflating the two layers is a common architectural smell to flag in code review.