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
- Spring Framework: mapping requests
- Spring Framework: validation
- Spring Framework: controller advice
- Spring Framework: REST error responses
Designing and testing these boundaries supports the applied backend work in the Forward-Deployed Engineer program.