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):
- Receives
HttpServletRequest - Consults
HandlerMappingto find the matching handler (controller method) + anyHandlerInterceptors - Wraps it in a
HandlerExecutionChain - Invokes
preHandle()on each interceptor - Delegates to
HandlerAdapterto actually invoke the controller method (handles argument resolution —@RequestBody,@PathVariable, etc.) - Receives
ModelAndView(or, for@RestController, the return value goes straight to anHttpMessageConverter) - Invokes
postHandle()on interceptors - If a view name was returned, consults
ViewResolverto resolve and render it - Invokes
afterCompletion()on interceptors - If an exception occurred anywhere, hands off to
HandlerExceptionResolverchain (this is where@ControllerAdviceplugs 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:
- Request hits the embedded server (Tomcat/Netty) → servlet container
- Filters (servlet-spec, container-level) run first —
FilterChain.doFilter() DispatcherServlet.doService()invokedHandlerMappingresolves handler + interceptor chain- Interceptor
preHandle() HandlerAdapterresolves method arguments, invokes controller method- If
@Validis present, JSR-303 validation runs before the method body executes; failures short-circuit to exception handling - Controller returns →
HttpMessageConverterserializes return value (REST) orModelAndViewis built (MVC) - Interceptor
postHandle() - View rendering (if applicable) or direct body write
- Interceptor
afterCompletion() - Response flows back out through the Filter chain (in reverse)
- 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
Exceptionhandler should always exist as a safety net to avoid leaking stack traces to clients. - Can scope to specific packages/controllers via
@ControllerAdvice(basePackages = "...")orassignableTypes— 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.,OnCreaterequires an ID to be absent,OnUpdaterequires 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.