Mastering Spring Boot MVC and REST APIs: A Comprehensive Guide

Published · Updated

A maintainable Spring Boot REST API treats HTTP as a real boundary: map resources to clear URLs, accept dedicated request types, validate untrusted input, return meaningful status codes, and make errors predictable. Spring MVC supplies the mechanics, but your API contract still needs deliberate design.

Version assumptions (updated 7 August 2026): Spring Boot 4.1.x, Java 17+, Spring Web, and the Validation starter. This article assumes you can already run a controller; use the first MVC/REST app if you need that foundation.

Start from an HTTP resource

For an orders resource, a small contract might be:

Operation Request Success response
List orders GET /api/orders 200 OK with an array
Read one GET /api/orders/{id} 200 OK, or 404 Not Found
Create POST /api/orders 201 Created with Location and a body

HTTP method semantics matter. A GET should not create or mutate an order. A POST that creates a resource should normally identify the new resource instead of returning an unqualified success string.

Keep request DTOs at the boundary

Do not bind an API request directly onto a persistence entity. A request DTO states what clients may submit and gives validation a focused target.

Add the spring-boot-starter-validation dependency using Initializr or this Maven dependency:

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

Define the request and response records:

package com.example.orders;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Positive;

public record CreateOrderRequest(
        @NotBlank String product,
        @Positive int quantity) {}
package com.example.orders;

public record OrderResponse(long id, String product, int quantity) {}

@NotBlank rejects null, empty, or whitespace-only product names. @Positive requires a quantity greater than zero. These rules protect the request shape; business rules such as inventory availability still belong in application code.

Return explicit statuses from the controller

package com.example.orders;

import java.net.URI;
import java.util.List;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public List<OrderResponse> list() {
        return orderService.findAll();
    }

    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable long id) {
        return orderService.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @PostMapping
    public ResponseEntity<OrderResponse> create(
            @Valid @RequestBody CreateOrderRequest request) {
        OrderResponse created = orderService.create(request);
        URI location = URI.create("/api/orders/" + created.id());
        return ResponseEntity.created(location).body(created);
    }
}

@RequestBody asks an HTTP message converter to deserialize JSON. @Valid triggers Bean Validation for the request record. ResponseEntity.created returns 201 Created and sets the Location header.

The service interface used above can remain small:

package com.example.orders;

import java.util.List;
import java.util.Optional;

public interface OrderService {
    List<OrderResponse> findAll();
    Optional<OrderResponse> findById(long id);
    OrderResponse create(CreateOrderRequest request);
}

Its persistence implementation is intentionally outside this article; inventing a database layer would distract from the HTTP contract.

Centralize API error mapping

First define a specific exception:

package com.example.orders;

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(long id) {
        super("Order " + id + " was not found");
    }
}

Then map it once for all controllers:

package com.example.orders;

import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    ProblemDetail handleNotFound(OrderNotFoundException exception) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, exception.getMessage());
        problem.setTitle("Order not found");
        problem.setType(URI.create("https://example.com/problems/order-not-found"));
        return problem;
    }
}

Spring MVC supports ProblemDetail for RFC 9457-style error bodies and selects application/problem+json during JSON content negotiation. Use a stable problem type URL on a domain you control in a real API; example.com is only a placeholder in this sample.

Validation failures also need a client-safe response. Spring raises MethodArgumentNotValidException for invalid @Valid @RequestBody values. A production API should map it to a stable 400 schema without returning stack traces or internal exception details.

Test the boundary at the right levels

Use focused tests for separate questions:

  • Service unit tests: business behavior without a Spring context.
  • MVC slice tests: request mapping, JSON conversion, validation, status, headers, and exception advice with a controlled service collaborator.
  • Integration tests: real serialization and persistence wiring where those boundaries create meaningful risk.
  • End-to-end tests: a small number of critical client journeys.

At minimum, cover a valid POST (201 plus Location), an invalid POST (400), an existing GET (200), and a missing GET (404). Test observable HTTP results, not the controller’s internal call sequence.

Production checklist

  • Authenticate and authorize every non-public operation; input validation is not access control.
  • Put limits on request body sizes and collections.
  • Do not expose stack traces, SQL errors, or secrets in responses.
  • Add pagination before an unbounded collection can grow.
  • Define idempotency behavior for creation endpoints that clients may retry.
  • Use HTTPS and configure cross-origin access only for known clients.
  • Record correlation identifiers and useful structured events without logging sensitive request bodies.
  • Publish and version the API contract before consumers depend on accidental behavior.

For the object wiring behind OrderController, revisit Spring IoC and constructor injection.

Primary references

Designing and testing these boundaries supports the applied backend work in the Forward-Deployed Engineer program.