Views & Model Binding
Model and ModelAndView for server-rendered views, form binding with @ModelAttribute, and validation with @Valid and BindingResult.
Server-rendered views: Model and ModelAndView
A plain @Controller (not @RestController) that renders HTML passes data to the view through a Model — a map-like object the view template reads from — and returns a logical view name:
@Controller
@RequestMapping("/products")
public class ProductPageController {
private final ProductService productService;
public ProductPageController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public String showProduct(@PathVariable Long id, Model model) {
model.addAttribute("product", productService.findById(id));
return "product-details"; // resolves to templates/product-details.html
}
}
Spring Boot's default template engine is Thymeleaf (via spring-boot-starter-thymeleaf), which renders natural HTML files with special attributes read at render time:
<!-- templates/product-details.html -->
<p th:text="${product.name}">Placeholder name</p>
<p th:text="${product.price}">0.00</p>
ModelAndView bundles the view name and model data into one return type, useful when the view name itself needs to be decided by logic rather than being a fixed string:
@GetMapping("/{id}")
public ModelAndView showProduct(@PathVariable Long id) {
Product product = productService.findById(id);
ModelAndView mav = new ModelAndView(product.isDiscontinued() ? "product-discontinued" : "product-details");
mav.addObject("product", product);
return mav;
}
In most controllers, returning a plain view-name String and populating Model as a parameter (the first example) is simpler and is what you'll see most often; ModelAndView earns its keep when the view name itself is conditional.
Form binding with @ModelAttribute
@ModelAttribute binds an entire HTML form submission's fields onto a Java object by matching input name attributes to property names:
public class ProductForm {
private String name;
private BigDecimal price;
private String category;
// getters and setters
}
@GetMapping("/new")
public String newProductForm(Model model) {
model.addAttribute("productForm", new ProductForm());
return "product-form";
}
@PostMapping
public String createProduct(@ModelAttribute ProductForm form) {
productService.create(form.getName(), form.getPrice(), form.getCategory());
return "redirect:/products";
}
<!-- templates/product-form.html -->
<form th:action="@{/products}" th:object="${productForm}" method="post">
<input type="text" th:field="*{name}" />
<input type="number" th:field="*{price}" />
<input type="text" th:field="*{category}" />
<button type="submit">Save</button>
</form>
Returning "redirect:/products" sends the browser an HTTP redirect rather than rendering a view directly — the standard pattern after a successful form submission (the Post/Redirect/Get pattern), which avoids resubmitting the form if the user refreshes the resulting page.
Validation with @Valid and BindingResult
Bean Validation annotations describe constraints directly on the form/DTO class:
public class ProductForm {
@NotBlank(message = "Name is required")
private String name;
@NotNull
@DecimalMin(value = "0.01", message = "Price must be greater than zero")
private BigDecimal price;
// getters and setters
}
Adding @Valid before the bound parameter triggers validation; a BindingResult parameter placed immediately after it captures any violations instead of Spring throwing an exception:
@PostMapping
public String createProduct(@Valid @ModelAttribute ProductForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "product-form"; // re-render the same form, now showing validation errors
}
productService.create(form.getName(), form.getPrice(), form.getCategory());
return "redirect:/products";
}
Thymeleaf reads validation errors straight off the bound object to show inline messages:
<input type="text" th:field="*{name}" />
<span th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Error</span>
For a JSON API (@RestController) the same @Valid annotation works on @RequestBody, but there's no BindingResult fallback pattern — a validation failure instead throws MethodArgumentNotValidException, which you typically handle globally with @ExceptionHandler/@ControllerAdvice to return a structured 400 response.
Common mistakes
- Forgetting that
BindingResultmust appear directly after the@Valid-annotated parameter — if anything else comes between them, Spring throws instead of populating it, defeating the whole point of graceful validation handling. - Returning a view name to re-render a form on validation failure but forgetting the model attribute needed by the template (
@ModelAttributealready re-adds the submitted object automatically in this flow, but other model attributes the template needs must be added explicitly again). - Not using
redirect:after a successful POST, leaving the browser one refresh away from resubmitting the form. - Mixing
@ModelAttributeform binding patterns into a@RestControllermeant for JSON clients — they're two different binding models (@ModelAttributefor form-encoded data,@RequestBodyfor JSON) and reach for different partners (BindingResultvs. exception handling).