What is Spring MVC, and Why Does the Front Controller Pattern Exist?
Spring MVC is the web layer of Spring Framework, built on top
of the Servlet API (jakarta.servlet.* since Spring Framework 6 /
Spring Boot 3 โ it was javax.servlet.* before). It implements the
Front Controller design pattern: every single HTTP request,
regardless of URL, is received by one object โ
DispatcherServlet โ which then delegates to the right handler.
The alternative โ one raw Servlet per URL, the pre-Spring
approach โ means every cross-cutting concern (authentication, logging,
exception formatting, content negotiation) has to be duplicated or
hand-wired into every single servlet. A front controller centralises all of
that in one place: the request always enters through
DispatcherServlet, which applies interceptors, resolves the
correct controller method, converts the response, and hands back a result โ
identically, for every endpoint in the application.
Everything on this page โ DispatcherServlet, controllers,
interceptors โ sits on top of the Spring container covered in
Spring Core. Every @Controller is
itself a Spring bean, built and wired the exact same way as any
@Service. When you use Spring Boot,
spring-boot-starter-web on the classpath is what triggers
auto-configuration of DispatcherServlet and an embedded
Tomcat โ see Spring Boot Basics for the mechanism
behind that.
Spring MVC is servlet-based and blocking: one thread is
occupied per in-flight request. Spring also ships
WebFlux, a separate reactive stack (non-blocking,
Mono/Flux) for high-concurrency I/O-bound
workloads. They share almost none of their runtime internals despite
similar annotations. WebFlux deserves โ and will get โ its own dedicated
page; don't conflate the two.
The Request Lifecycle
/*
* Browser โโโถ DispatcherServlet โโโถ HandlerMapping โโโถ Controller method
* โ โ
* โ returns "view name" + Model
* โ OR a @ResponseBody / ResponseEntity
* โ โ
* โโโโโโโโโโโดโโโโโโโโโโ โโโโโโโโโโโโโดโโโโโโโโโโโโ
* โผ โผ โผ โผ
* HandlerAdapter ViewResolver HttpMessageConverter (nothing to convert:
* (invokes the method (view name โ (object โ JSON, void / already sent)
* with the right View instance) via Jackson)
* arguments resolved) โ โ
* โผ โผ
* View.render() Response body written
* โ โ
* โโโโโโโโโโโโโฌโโโโโโโโโโโโ
* โผ
* HTTP Response to Browser
*/
- DispatcherServlet receives the request โ the single entry point for the whole application.
- HandlerMapping finds which controller method matches the URL, HTTP method, headers, and content type.
- HandlerAdapter actually invokes that method โ resolving each parameter
(
@PathVariable,@RequestBody,Model, etc.) into the right argument. - The method returns either a view name + Model (traditional, server-rendered) or
data directly (
@ResponseBody/ResponseEntityโ the REST path). - View path:
ViewResolverturns the view name into an actualView(e.g. a Thymeleaf template), which renders the Model into HTML. - Data path: an
HttpMessageConverter(Jackson, by default) serialises the returned object straight into the response body โViewResolveris never involved.
These are commonly conflated. HandlerMapping's only job is
deciding which method should handle this request.
HandlerAdapter's job is invoking that method
correctly โ resolving every parameter via the registered
HandlerMethodArgumentResolvers (one exists for
@PathVariable, one for @RequestBody, one for
Model, and so on). This separation is why adding support for
a brand-new parameter annotation doesn't require touching routing logic
at all โ you register a new argument resolver, and the existing
HandlerMapping is untouched.
Declaring Controllers
@Controller // resolves a VIEW NAME โ for server-rendered HTML
public class HomeController {
@GetMapping("/")
public String home(Model model) {
model.addAttribute("message", "Welcome!");
return "home"; // ViewResolver maps this to a template, e.g. templates/home.html
}
}
@RestController // = @Controller + @ResponseBody โ writes the return value to the body directly
public class ApiController {
@GetMapping("/api/data")
public DataResponse getData() {
return new DataResponse("Hello"); // serialised to JSON by Jackson via HttpMessageConverter
}
}
Since Spring 4.3, @GetMapping, @PostMapping,
@PutMapping, @DeleteMapping and
@PatchMapping are all shorthand meta-annotations of
@RequestMapping(method = RequestMethod.X). There is no
separate mechanism โ writing @RequestMapping(method =
RequestMethod.GET) today is just more verbose, not more correct.
@Controller
@RequestMapping("/products") // base path โ combined with each method's mapping
public class ProductController {
private final ProductService productService; // constructor injection โ same rule as any other Spring bean
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping // GET /products
public String list(Model model) {
model.addAttribute("products", productService.findAll());
return "products/list";
}
@GetMapping("/{id}") // GET /products/123
public String show(@PathVariable Long id, Model model) {
model.addAttribute("product", productService.findById(id));
return "products/show";
}
@PostMapping // POST /products
public String create(@ModelAttribute Product product) {
productService.save(product);
return "redirect:/products"; // redirect after POST โ prevents duplicate submission on refresh
}
}
Binding Request Data
@RestController
public class SearchController {
// Path variable โ /users/123 โ id = 123
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { ... }
// Query parameters โ /search?q=java&page=2
@GetMapping("/search")
public List<Result> search(
@RequestParam String q, // required by default โ 400 if missing
@RequestParam(defaultValue = "1") int page,
@RequestParam(required = false) String sort) { ... }
// Header and cookie
@GetMapping("/data")
public String getData(@RequestHeader("Authorization") String authHeader,
@CookieValue("theme") String theme) { ... }
// JSON body โ converted by an HttpMessageConverter (Jackson), based on Content-Type
@PostMapping("/users")
public User createUser(@RequestBody User user) { ... }
// Form fields (application/x-www-form-urlencoded) โ bound to object properties
@PostMapping("/register")
public String register(@ModelAttribute RegistrationForm form) { ... }
}
Spring matches @PathVariable Long id to the {id}
template segment by parameter name. That only works if the
compiler retained parameter names in the bytecode โ which requires the
-parameters compiler flag. Spring Boot's Maven and Gradle
plugins enable it by default, so most Boot projects never notice. Compile
outside those plugins, or with an IDE run configuration that strips it,
and every implicit-name @PathVariable breaks at runtime with
an unhelpful error โ the fix, and the safe default in library code, is
always specifying the name explicitly: @PathVariable("id") Long
id.
Spring maintains a list of HttpMessageConverters. For JSON,
that's MappingJackson2HttpMessageConverter โ auto-registered
by Spring Boot the moment jackson-databind is on the
classpath (it is, transitively, via
spring-boot-starter-web). Which converter runs is decided by
content negotiation: the request's Content-Type
header for deserialising @RequestBody, and the request's
Accept header for serialising the response.
Model, View Resolution, and Returning Data Directly
@Controller
public class DashboardController {
@GetMapping("/dashboard")
public String dashboard(Model model) {
model.addAttribute("stats", statsService.currentStats());
return "dashboard"; // resolved via spring.thymeleaf.prefix/suffix
}
// Runs before EVERY handler method in this controller โ shared model data
@ModelAttribute("categories")
public List<Category> categories() {
return categoryService.findAll();
}
}
By default, a Thymeleaf-backed Boot app resolves the view name
"dashboard" to
src/main/resources/templates/dashboard.html โ the
spring.thymeleaf.prefix (classpath:/templates/) and
spring.thymeleaf.suffix (.html) properties, which
you rarely need to touch. Binding into the template happens through
Thymeleaf's th:* attributes reading straight from the Model:
<!-- templates/dashboard.html -->
<h1 th:text="${stats.totalOrders}">0</h1>
<select>
<option th:each="cat : ${categories}" th:value="${cat.id}" th:text="${cat.name}"></option>
</select>
Server-rendered views (Model + ViewResolver +
Thymeleaf) are still legitimate for admin panels or SEO-sensitive pages,
but the dominant pattern for new backends is @RestController +
ResponseEntity<T> โ the frontend is a separate SPA consuming
JSON. ResponseEntity gives explicit control over status code,
headers, and body in one object, and is generally preferred over the
static @ResponseStatus annotation when the status genuinely
depends on runtime logic:
@GetMapping("/{id}")
public ResponseEntity<Product> show(@PathVariable Long id) {
return productService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
Form Handling and Validation
public class ProductForm {
@NotBlank
private String name;
@Positive
private BigDecimal price;
// getters/setters
}
@PostMapping("/products")
public String create(@Valid @ModelAttribute("product") ProductForm form,
BindingResult result,
RedirectAttributes redirectAttrs) {
if (result.hasErrors()) {
return "products/form"; // re-show form with field errors bound automatically
}
Product product = productService.create(form);
redirectAttrs.addFlashAttribute("message", "Product created!"); // survives exactly one redirect
return "redirect:/products/" + product.getId();
}
With @ModelAttribute, a BindingResult parameter
placed immediately after the validated argument tells
Spring "I'll handle errors myself" โ validation failures populate
result silently, no exception is thrown. With
@RequestBody, there is no equivalent convention: a validation
failure always throws MethodArgumentNotValidException,
caught via @ExceptionHandler โ you cannot silently absorb it
the way you can with a form-backing object. Forgetting this is a common
source of "why did my form validation crash the whole request" bugs when
code is copy-pasted between REST and view-based controllers.
Exception Handling
// Local โ only catches exceptions thrown within THIS controller
@Controller
public class ProductController {
@ExceptionHandler(ProductNotFoundException.class)
public String handleNotFound(ProductNotFoundException ex, Model model) {
model.addAttribute("message", ex.getMessage());
return "error/404";
}
}
Before Spring Framework 6, every codebase invented its own
ErrorResponse record โ inconsistent shape across teams and
clients. Spring 6 / Boot 3 ship ProblemDetail, a built-in
implementation of RFC 9457 ("Problem Details for HTTP APIs"). It is
opt-in โ set
spring.mvc.problemdetails.enabled=true to have Spring's
default exception resolver produce it automatically for exceptions it
already understands (validation errors, 404s from missing handlers,
etc). For your own exceptions, build one explicitly:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Product Not Found");
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
problem.setProperty("errors", ex.getBindingResult().getFieldErrors()
.stream().map(e -> e.getField() + ": " + e.getDefaultMessage()).toList());
return problem;
}
}
A single, consistent JSON shape (type, title,
status, detail, plus your own custom properties)
across every endpoint and every client โ instead of every team's own
bespoke error object.
Interceptors vs Filters โ Not the Same Layer
| Aspect | Filter (jakarta.servlet.Filter) | HandlerInterceptor |
|---|---|---|
| Runs at | Servlet container level, before DispatcherServlet | Inside DispatcherServlet's own pipeline |
| Knows the target handler? | No โ has no idea which controller method will run | Yes โ receives the resolved HandlerMethod |
| Can wrap raw streams? | Yes (e.g. GZIPInputStream wrapping) |
No โ operates on the already-parsed request |
| Typical use | CORS, compression, raw auth token checks, request logging | Auth checks needing handler metadata (e.g. a custom @RequiresRole annotation on
the method), timing per-endpoint |
@Component
public class TimingInterceptor implements HandlerInterceptor {
// Since Spring 5.3, all three methods have default implementations โ
// override only the ones you actually need.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute("startTime", System.nanoTime());
return true; // false would short-circuit โ controller never runs
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
long nanos = System.nanoTime() - (long) request.getAttribute("startTime");
log.debug("{} took {}ms", request.getRequestURI(), nanos / 1_000_000);
}
}
// Single WebConfig โ registers both interceptors AND resource handlers.
// Two classes named WebConfig in the same package is a compile error, not a style choice.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final TimingInterceptor timingInterceptor;
public WebConfig(TimingInterceptor timingInterceptor) {
this.timingInterceptor = timingInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(timingInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/static/**");
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/files/**")
.addResourceLocations("file:/var/uploads/")
.setCacheControl(CacheControl.maxAge(Duration.ofHours(1)));
}
}
Static Resources
By default, Spring Boot serves anything placed under
src/main/resources/static/ (also checked, in order:
/public/, /resources/,
/META-INF/resources/) directly, without a controller.
# application.properties โ override the default locations or caching
spring.web.resources.static-locations=classpath:/static/,classpath:/custom/
spring.web.resources.cache.period=31536000 # seconds โ 1 year, safe with content-hashed filenames
spring.web.resources.chain.strategy.content.enabled=true
appends a content hash to static filenames
(app-8f3a1c.js). Combined with a 1-year
cache.period, this is safe: the browser caches indefinitely,
and a genuinely new file gets a genuinely new URL, forcing a fetch. A long
cache period without content hashing means users can get stuck on stale
JS/CSS until the cache naturally expires.
Testing the Web Layer: @WebMvcTest and MockMvc
@WebMvcTest(ProductController.class) // loads ONLY the web layer โ this controller + relevant MVC infra
class ProductControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean // no real ProductService bean exists in this slice โ must be mocked
private ProductService productService;
@Test
void show_returnsProductView() throws Exception {
when(productService.findById(1L)).thenReturn(new Product(1L, "Keyboard"));
mockMvc.perform(get("/products/1"))
.andExpect(status().isOk())
.andExpect(view().name("products/show"))
.andExpect(model().attributeExists("product"));
}
}
Exactly as covered in Spring Core: reach for the
narrowest context that actually tests what you need. A plain unit test
with Mockito needs no Spring at all. @WebMvcTest loads the
web layer only โ no repositories, no real database โ making it far
faster than a full @SpringBootTest while still exercising
real routing, real argument binding, and real JSON serialisation.
Interview Questions
Q: What is DispatcherServlet, and why does Spring MVC use a single one instead of a
servlet per URL?
It's the Front Controller โ a single entry point for every HTTP request.
Centralising the entry point means cross-cutting concerns (interceptors,
exception handling, content negotiation) are applied consistently once,
instead of being duplicated across many individual servlets.
Q: What's the difference between @Controller and @RestController?
@RestController is @Controller +
@ResponseBody. @Controller methods return a view
name to be resolved and rendered; @RestController methods return
data that's written directly to the response body via an
HttpMessageConverter (JSON, typically).
Q: What's the difference between @PathVariable and @RequestParam?
@PathVariable extracts a value from the URL path itself
(/users/{id}). @RequestParam extracts a value from
the query string (?page=2) or form data.
Q: When would you choose a Filter over a HandlerInterceptor, or vice versa?
A Filter runs at the servlet container level, before
DispatcherServlet even executes, and has no knowledge of which
controller method will eventually run โ appropriate for concerns that must
apply universally and early: CORS, compression, raw token presence checks. A
HandlerInterceptor runs inside the MVC pipeline with access to
the resolved HandlerMethod, making it the right choice when the
logic needs handler-level metadata โ e.g. reading a custom annotation on the
target method to decide whether to allow the request through.
Q: What actually happens if @Valid is used on a @RequestBody parameter without a
following BindingResult, versus with @ModelAttribute?
With @RequestBody, there's no convention for absorbing errors โ
a validation failure always throws
MethodArgumentNotValidException, which must be caught via
@ExceptionHandler. With @ModelAttribute, placing a
BindingResult parameter immediately after the validated argument
tells Spring to populate it silently instead โ no exception, and the code
must explicitly check result.hasErrors(). Copy-pasting a
validation pattern between a REST controller and a form controller without
accounting for this difference is a common source of unhandled-exception
bugs.
Q: How does Spring resolve @PathVariable Long id to the {id} segment without an explicit
name โ and what breaks it?
It matches by parameter name, which only survives compilation if the
-parameters javac flag was used to retain parameter names in
the bytecode. Spring Boot's Maven and Gradle plugins enable this by default,
which is why most developers never see the failure mode โ but any build
outside those defaults (a raw javac invocation, some IDE run
configurations) silently loses this, and every implicit-name
@PathVariable breaks at runtime. The defensive default in shared
or library code is naming it explicitly: @PathVariable("id").
Q: What is ProblemDetail and why did Spring Framework 6 introduce it?
It's Spring's built-in implementation of RFC 9457 ("Problem Details for HTTP
APIs"), standardising error response shape (type,
title, status, detail, plus custom
properties) across an entire API surface instead of every team inventing its
own ErrorResponse DTO. It's opt-in via
spring.mvc.problemdetails.enabled=true for Spring's own default
exception handling, but can โ and typically should โ be returned explicitly
from custom @ExceptionHandler methods regardless of that flag.