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:

Java
@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:

HTML
<!-- 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:

Java
@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:

Java
public class ProductForm {
    private String name;
    private BigDecimal price;
    private String category;

    // getters and setters
}
Java
@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";
}
HTML
<!-- 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:

Java
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:

Java
@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:

HTML
<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 BindingResult must 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 (@ModelAttribute already 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 @ModelAttribute form binding patterns into a @RestController meant for JSON clients — they're two different binding models (@ModelAttribute for form-encoded data, @RequestBody for JSON) and reach for different partners (BindingResult vs. exception handling).