Spring MVC Architecture

The Front Controller pattern, the request lifecycle, and what actually happens between a browser and your @Controller

← Back to Index

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.

Where this fits with what you already know

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.

This page covers Spring MVC โ€” not Spring WebFlux

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
 */
  1. DispatcherServlet receives the request โ€” the single entry point for the whole application.
  2. HandlerMapping finds which controller method matches the URL, HTTP method, headers, and content type.
  3. HandlerAdapter actually invokes that method โ€” resolving each parameter (@PathVariable, @RequestBody, Model, etc.) into the right argument.
  4. The method returns either a view name + Model (traditional, server-rendered) or data directly (@ResponseBody / ResponseEntity โ€” the REST path).
  5. View path: ViewResolver turns the view name into an actual View (e.g. a Thymeleaf template), which renders the Model into HTML.
  6. Data path: an HttpMessageConverter (Jackson, by default) serialises the returned object straight into the response body โ€” ViewResolver is never involved.
HandlerMapping vs HandlerAdapter โ€” a real distinction, not a technicality

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
    }
}
@GetMapping etc. are meta-annotations, not separate features

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) { ... }
}
@PathVariable("id") โ€” why the explicit name sometimes matters

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.

@RequestBody / @ResponseBody are driven by HttpMessageConverter, not magic

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>
The production reality: most new backends skip views entirely

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();
}
@Valid behaves differently depending on what follows it

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";
    }
}
Modern REST error responses: ProblemDetail (RFC 9457), not a hand-rolled DTO

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
Content-hashed filenames beat a long cache period alone

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"));
    }
}
The same lesson as @SpringBootTest โ€” a smaller slice, not a smaller framework

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

๐ŸŽ“ Junior level

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.

๐Ÿ”ฅ Senior level

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.