Spring Boot – Spring Web MVC

June 29, 20264 min readUpdated 8/18/2026

Spring Web MVC is the servlet-based web framework underneath every @RestController you write. You can be productive without knowing how a request reaches your method — until something goes wrong in the gap between the socket and your code, at which point knowing the path is the difference between a fix and a guess.

One dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

⚠️ In Boot 3 this was spring-boot-starter-web. Boot 4 renamed it and the old name is not in the BOM — see lesson 3.

That starter brings Spring MVC, Jackson, an embedded Tomcat and validation glue, and auto-configures a DispatcherServlet mapped to /.

How a request reaches your method

HTTP request
    │
    ▼
Servlet container (embedded Tomcat)
    │
    ▼
Filter chain            ← JwtAuthenticationFilter, CORS, Spring Security live here
    │
    ▼
DispatcherServlet       ← the front controller: one servlet for every request
    │
    ├─▶ HandlerMapping          which method handles this URL and verb?
    ├─▶ HandlerAdapter          invoke it
    │     ├─▶ ArgumentResolvers   build the method's parameters
    │     │     @PathVariable · @RequestParam · @RequestBody · @RequestPart
    │     │
    │     └─▶ your controller method
    │
    ├─▶ HttpMessageConverter    turn the return value into a response body
    └─▶ HandlerExceptionResolver  if anything threw  →  @RestControllerAdvice

Three things are worth internalising from that diagram:

  • Filters run before the DispatcherServlet. Spring Security is a filter, so a 401 or 403 can be produced before your controller is ever consulted — which is why an endpoint can return 403 for a URL that has no handler at all.
  • Argument resolvers build your parameters. @RequestBody is not magic; it is a resolver calling a message converter.
  • Message converters produce the body. Returning a ProductDTO works because Jackson is on the classpath and a converter knows what to do with it.

Mapping requests

@Tag(name = "Products", description = "Menu browsing (public)")
@RequestMapping("/api/products")
@RestController
@Slf4j
public class ProductRestController {

    @Autowired
    private ProductService productService;

    @Operation(summary = "List the active menu, optionally filtered by type")
    @GetMapping
    public ResponseEntity<List<ProductDTO>> getProducts(
            @RequestParam(required = false) ProductType type) {
        // …
    }

    @Operation(summary = "Get one product with its sizes")
    @GetMapping("/{id}")
    public ResponseEntity<ProductDTO> getProduct(@PathVariable UUID id) {
        log.info("GET /api/products/{}", id);
        return new ResponseEntity<>(productService.getProductByPublicId(id), OK);
    }
}

@RequestMapping on the class sets the prefix; @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping are shorthands for it with a method.

Notice the parameter types: UUID and ProductType, not String. Spring converts, and a value that will not convert becomes a MethodArgumentTypeMismatchException before your method runs — so the method body never has to consider malformed input. Lesson 12 turns that into a clean 400.

Binding parameters

// Path segment
@GetMapping("/{id}")
public ProductDTO get(@PathVariable UUID id) { }

// Query string: /api/products?type=DRINK
@GetMapping
public List<ProductDTO> list(@RequestParam(required = false) ProductType type) { }

// With a default
@GetMapping("/search")
public List<ProductDTO> search(
        @RequestParam String q,
        @RequestParam(defaultValue = "0") int page) { }

// JSON body, validated
@PostMapping
public ProductDTO create(@Valid @RequestBody ProductCreateDTO dto) { }

// One named part of a multipart body
@PostMapping(value = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ProductDTO upload(@PathVariable UUID id, @RequestPart("file") MultipartFile file) { }

// A header
@PostMapping("/stripe")
public ResponseEntity<String> webhook(
        @RequestBody String payload,
        @RequestHeader(value = "Stripe-Signature", required = false) String signature) { }

required = false versus defaultValue: the first gives you null and a decision to make, the second gives you a value. Prefer defaultValue when there is a sensible one — it removes a null check from your method.

Message converters

Return an object and Jackson serialises it. Return ResponseEntity<T> and you also control the status and headers:

return ResponseEntity.ok()
        .contentType(MediaType.IMAGE_JPEG)
        .cacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic())
        .header("Content-Disposition", "inline; filename=\"" + fileName + "\"")
        .body(image);

Converters are chosen by the return type and the request's Accept header. Returning a Resource rather than a byte[] matters for files: Spring streams it, so a 2 MB image never sits in heap in its entirety. With byte[] the whole file is buffered per concurrent request.

CORS

A browser refuses cross-origin requests unless the server opts in. The pizza API's React app runs on :5173 and the API on :8085 — different origins, so CORS is required.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(properties.cors().allowedOrigins());
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

⚠️ CORS and Spring Security have to agree. Security runs as a filter, ahead of MVC, so it can reject the browser's preflight OPTIONS request before any CORS handling happens — and the browser then reports a CORS error for what is really an authentication problem. The fix is to let Security use the same configuration:

http.cors(Customizer.withDefaults())   // picks up the CorsConfigurationSource bean above

Also note allowCredentials(true) is incompatible with allowedOrigins("*"). The spec forbids it, and the error message is about the wildcard rather than about credentials.

Static resources

Anything in src/main/resources/static/ is served from the classpath automatically. Add your own mappings when you need to:

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/webjars/**")
            .addResourceLocations("classpath:/META-INF/resources/webjars/");
}

What to take from this

  • Boot 4: spring-boot-starter-webmvc.
  • Filters run before the DispatcherServlet, which is why security errors can appear for URLs that have no handler.
  • Bind to real typesUUID, enums — and let conversion failures become 400s rather than method-body checks.
  • Make CORS and Security use the same configuration, or preflight failures will masquerade as CORS bugs.

Next: building a REST API — status codes, DTOs, and why the pizza API exposes a UUID instead of its primary key.