Spring MVC Tutorial: Learn Web Framework from Scratch (2026)
Spring MVC is the foundation of most Java web applications. Its Front Controller pattern, with DispatcherServlet at the center, provides a clean separation between HTTP handling, business logic, and view rendering. Over the years I have built everything from simple CRUD dashboards to complex multi-tenant portals on Spring MVC, and its request-to-controller mapping remains one of the most intuitive designs in the Java ecosystem.
This tutorial covers the request lifecycle, controller design, validation, view resolution, content negotiation, and async support. You will learn to use Spring MVC effectively whether you render server-side templates or build JSON APIs.
Request Lifecycle and DispatcherServlet
The DispatcherServlet intercepts all HTTP requests and routes them through a chain of interceptors, argument resolvers, and handler mappings before reaching your controller. Understanding this flow helps debug performance issues and customize behavior. HandlerMapping locates the right controller method, HandlerAdapter invokes it, and ViewResolver translates the returned view name into a rendered response.
You can plug custom interceptors to handle cross-cutting concerns like request logging, rate limiting, or tenant resolution without touching controller logic. Each interceptor implements HandlerInterceptor with preHandle, postHandle, and afterCompletion methods.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new RequestTimingInterceptor())
.addPathPatterns("/api/**");
}
}
public class RequestTimingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
req.setAttribute("startTime", System.currentTimeMillis());
return true;
}
}
Controller Design with Annotations
@Controller marks a class for MVC, while @RestController combines @Controller with @ResponseBody for JSON endpoints. Use @RequestMapping, @GetMapping, @PostMapping for URL mapping. Method arguments like @RequestParam, @PathVariable, @RequestBody, and @ModelAttribute bind request data to parameters automatically.
Keep controller methods thin — they should parse input, delegate to a service, and return a response. Avoid putting business logic in controllers. Use DTOs for request/response objects rather than exposing entity classes directly.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
}
Data Validation and Error Handling
Jakarta Bean Validation annotations (@NotBlank, @Email, @Size, @Pattern) on DTO fields trigger automatic validation when combined with @Valid. Custom validation logic goes into a class implementing ConstraintValidator. Return validation errors in a consistent format so clients can display field-level messages.
@ControllerAdvice classes handle exceptions globally. Map MethodArgumentNotValidException to 400 with field errors, AccessDeniedException to 403, and generic Exception to 500. Include a correlation ID in every error response for log correlation.
public record CreateUserRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 100) String password,
@NotNull @Past LocalDate dateOfBirth
) {}
@ControllerAdvice
public class ValidationAdvice {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map handle(MethodArgumentNotValidException ex) {
return ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
e -> e.getDefaultMessage()));
}
}
View Resolution and Template Engines
Spring MVC supports multiple view technologies. Thymeleaf is the modern choice for server-side HTML rendering, replacing JSP with natural templates that work as static prototypes. Configure prefix/suffix in application.properties and return view names from controllers.
For single-page applications, skip server-side views entirely. Return JSON from @RestController and let the frontend framework (React, Vue, Angular) handle rendering. This decouples backend from frontend and lets each team evolve independently.
// application.properties
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
@Controller
@RequestMapping("/profile")
public class ProfileController {
@GetMapping
public String profilePage(Model model) {
model.addAttribute("user", userService.getCurrentUser());
return "profile";
}
}
Content Negotiation
Spring MVC can serve multiple formats from the same endpoint using content negotiation. Based on the Accept header or URL suffix (.json, .xml), it selects the appropriate HttpMessageConverter. JSON with Jackson is the default, but you can add converters for XML, CSV, or YAML.
Use the produces attribute on @RequestMapping to restrict acceptable media types. This prevents clients from accidentally requesting unsupported formats and lets you version APIs via media type (application/vnd.example.v1+json).
@GetMapping(value = "/api/reports/{id}",
produces = {MediaType.APPLICATION_JSON_VALUE, "application/x-yaml"})
public Report getReport(@PathVariable Long id) {
return reportService.generate(id);
}
// curl -H "Accept: application/x-yaml" /api/reports/1
Async Requests and Server-Sent Events
DeferredResult and Callable let controllers handle requests asynchronously without tying up container threads. Return a DeferredResult immediately; a separate thread sets its value when the result is ready. This pattern is ideal for long-polling or integrating with message queues.
Server-Sent Events (SSE) push real-time updates to clients over a single HTTP connection. Spring MVC's SseEmitter manages the connection lifecycle. Use SSE for live dashboards, notifications, or progress updates where WebSocket would be overkill.
@GetMapping("/stream/orders")
public SseEmitter streamOrders() {
SseEmitter emitter = new SseEmitter(30_000L);
orderEventPublisher.subscribe(event -> {
try {
emitter.send(SseEmitter.event()
.name("order-update")
.data(event));
} catch (IOException e) {
emitter.completeWithError(e);
}
});
return emitter;
}
Frequently Asked Questions
What is the difference between @Controller and @RestController?
@Controller is used for MVC applications that return view templates. @RestController adds @ResponseBody to every method, meaning return values are written directly to the HTTP response body as JSON or XML.
How do I handle file uploads in Spring MVC?
Use MultipartFile as a controller parameter. Configure multipart resolver properties (max-file-size, max-request-size) in application.properties. Store files on disk, S3, or your CDN rather than in the database.
What is the role of the Model object in Spring MVC?
Model is a holder for attributes that get passed to the view template. Add data via model.addAttribute("key", value) and access it in Thymeleaf or JSP using ${key}. For REST controllers, use DTOs instead of Model.
How do I version REST APIs in Spring MVC?
Common strategies: URL path (/api/v1/orders), query parameter (?version=1), custom header (X-API-Version: 1), or Accept header media type versioning. Media type versioning is the most RESTful and flexible approach.
Originally published on Ayodhyyya. Last updated June 1, 2026.