REST APIs with Spring MVC and WebFlux
|
This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against. This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page covers building HTTP REST APIs on the servlet stack with Spring MVC — annotated controllers, request/response mapping, validation, and centralized error handling — and then contrasts it with the reactive stack, Spring WebFlux, including when to reach for each.
GraphQL is covered in its own GraphQL Reference rather than as a REST/gRPC alternative here.
Spring MVC: annotated REST controllers
A @RestController combines @Controller and @ResponseBody: every handler method’s return value is
serialized directly into the HTTP response body (typically as JSON, via Jackson) instead of being resolved as a
view name.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public List<OrderSummary> findAll(@RequestParam(required = false) String status) {
return orderService.findAll(status);
}
@GetMapping("/{id}")
public OrderDetail findOne(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderDetail create(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
@PutMapping("/{id}")
public OrderDetail update(@PathVariable Long id, @Valid @RequestBody UpdateOrderRequest request) {
return orderService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
orderService.delete(id);
}
}
@PathVariable binds a URI template segment such as /api/orders/{id} to a method parameter;
@RequestParam binds a query-string parameter; @RequestBody deserializes the request body into a Java
object; @RequestHeader and @CookieValue bind headers and cookies the same way. The @GetMapping /
@PostMapping / @PutMapping / @PatchMapping / @DeleteMapping annotations are shorthands for
@RequestMapping(method = …).
For a full response with explicit status, headers, or a conditionally-absent body, return
ResponseEntity<T> instead of the bare payload:
@GetMapping("/{id}")
public ResponseEntity<OrderDetail> findOne(@PathVariable Long id) {
return orderService.findByIdOptional(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<OrderDetail> create(@Valid @RequestBody CreateOrderRequest request) {
OrderDetail created = orderService.create(request);
URI location = URI.create("/api/orders/" + created.id());
return ResponseEntity.created(location).body(created);
}
See the official Spring Web MVC reference for the full request-mapping model (content negotiation, matrix variables, multipart uploads, and more), and Spring Boot’s servlet web applications reference for how Spring Boot auto-configures the embedded servlet container, Jackson, and error handling on top of it.
Request and response mapping details
Method parameters and return types are resolved by argument resolvers and return-value handlers. A few that come up constantly when building REST APIs:
@GetMapping("/{id}/items/{itemId}")
public OrderItem findItem(@PathVariable Long id, @PathVariable("itemId") Long itemId) {
return orderService.findItem(id, itemId);
}
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<OrderSummary> findAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestHeader("X-Client-Id") String clientId) {
return orderService.findPage(page, size, clientId);
}
Record classes are a natural fit for both request and response DTOs since Jackson can (de)serialize them without extra configuration, and their immutability keeps API payload shapes explicit.
The page/size shape above is offset pagination, and it inherits offset pagination’s cost on a deep page (see
Pagination: Offset vs. Keyset). For an endpoint whose result set gets
paged deeply, expose an opaque cursor parameter instead, mapped to the last-seen key, and return the next
cursor alongside each response’s results:
@GetMapping
public List<OrderSummary> list(@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") int size) {
return orderService.findAfter(cursor, size);
}
orderService.findAfter would typically decode cursor into a ScrollPosition and use the repository’s
Window<T> scrolling method (see Spring Data Overview) to run
that as an indexed keyset query rather than an OFFSET-backed one.
Bean Validation with @Valid
Annotate request DTOs with Bean Validation (JSR 380 / Jakarta Validation) constraints, and add @Valid to the
@RequestBody parameter so Spring MVC validates the payload before the handler method runs:
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<@Valid OrderLineRequest> lines,
@Positive BigDecimal discountLimit) {
}
public record OrderLineRequest(
@NotBlank String sku,
@Min(1) int quantity) {
}
A failed validation raises a MethodArgumentNotValidException, which Spring Boot’s default error handling
turns into a 400 Bad Request. The next section shows how to shape that response yourself instead of relying
on the default body.
Centralized exception handling
@ExceptionHandler and @ControllerAdvice
A @ExceptionHandler method maps a thrown exception to an HTTP response. Declared inside a controller it
applies only to that controller; declared inside a @ControllerAdvice (or @RestControllerAdvice, which adds
@ResponseBody) it applies globally across all controllers:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Order not found");
problem.setProperty("orderId", ex.getOrderId());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation failed");
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
problem.setProperty("errors", errors);
return ResponseEntity.badRequest().body(problem);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleUnexpected(Exception ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
return ResponseEntity.internalServerError().body(problem);
}
}
ProblemDetail and RFC 9457
ProblemDetail is Spring’s implementation of the RFC 9457
"Problem Details for HTTP APIs" format (type, title, status, detail, instance, plus arbitrary
extension properties via setProperty). Spring Boot applications can also opt into it globally, so that
every unhandled exception — not just the ones covered by an explicit @ExceptionHandler — is rendered as
a ProblemDetail body automatically:
spring:
mvc:
problemdetails:
enabled: true
A controller (or @RestControllerAdvice) can also extend ResponseEntityExceptionHandler, which already
handles the standard Spring MVC exceptions (binding errors, missing parameters, unsupported media types, and
so on) as ProblemDetail responses and lets subclasses override just the cases they need to customize.
See Spring Boot’s servlet web applications
reference (error handling section) and the
Spring Web MVC reference for the full
exception-resolution chain (HandlerExceptionResolver).
Spring WebFlux: reactive REST APIs
Spring WebFlux is Spring’s alternative, non-blocking web stack, built on Project Reactor’s Mono/Flux types
and running on Netty (or another asynchronous server) instead of the servlet API’s one-thread-per-request
model. WebFlux offers two equally-capable programming models: annotated controllers (the same annotations as
Spring MVC, but methods return Mono<T>/Flux<T>) and functional endpoints (RouterFunction).
Annotated controllers on WebFlux
@RestController
@RequestMapping("/api/orders")
public class ReactiveOrderController {
private final ReactiveOrderService orderService;
public ReactiveOrderController(ReactiveOrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public Flux<OrderSummary> findAll() {
return orderService.findAll();
}
@GetMapping("/{id}")
public Mono<ResponseEntity<OrderDetail>> findOne(@PathVariable Long id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
public Mono<ResponseEntity<OrderDetail>> create(@Valid @RequestBody Mono<CreateOrderRequest> request) {
return request
.flatMap(orderService::create)
.map(created -> ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created));
}
}
Functional endpoints with RouterFunction
Functional endpoints separate routing from the handler logic: a RouterFunction<ServerResponse> maps
predicates (path, method, headers) to HandlerFunction<ServerResponse> implementations, all composed in plain
Java rather than declared via annotations:
@Configuration
public class OrderRoutes {
@Bean
public RouterFunction<ServerResponse> orderRoutes(OrderHandler handler) {
return RouterFunctions.route()
.GET("/api/orders", handler::findAll)
.GET("/api/orders/{id}", handler::findOne)
.POST("/api/orders", handler::create)
.build();
}
}
@Component
public class OrderHandler {
private final ReactiveOrderService orderService;
public OrderHandler(ReactiveOrderService orderService) {
this.orderService = orderService;
}
public Mono<ServerResponse> findAll(ServerRequest request) {
return ServerResponse.ok().body(orderService.findAll(), OrderSummary.class);
}
public Mono<ServerResponse> findOne(ServerRequest request) {
Long id = Long.valueOf(request.pathVariable("id"));
return orderService.findById(id)
.flatMap(order -> ServerResponse.ok().bodyValue(order))
.switchIfEmpty(ServerResponse.notFound().build());
}
public Mono<ServerResponse> create(ServerRequest request) {
return request.bodyToMono(CreateOrderRequest.class)
.flatMap(orderService::create)
.flatMap(created -> ServerResponse
.created(URI.create("/api/orders/" + created.id()))
.bodyValue(created));
}
}
Functional endpoints favor explicit composition and are easier to unit test without a running server
(HandlerFunction methods are plain functions of ServerRequest → Mono<ServerResponse>); annotated
controllers favor familiarity and consistency with an existing Spring MVC codebase. Both compile down to the
same HttpHandler and can be mixed in the same application.
Choosing Spring MVC vs. Spring WebFlux
| Concern | Spring MVC (servlet) | Spring WebFlux (reactive) |
|---|---|---|
Concurrency model |
One thread per request (blocking I/O), thread pool sized to expected concurrency |
Small, fixed event-loop thread pool; non-blocking I/O throughout the stack |
Best fit |
Typical CRUD services, blocking JDBC/JPA data access, simplicity |
High-concurrency I/O-bound workloads (many slow downstream calls), server-sent events / streaming responses |
Data access |
Blocking drivers (JDBC, most JPA providers) |
Requires non-blocking drivers end-to-end (R2DBC, reactive Mongo/Redis clients) — mixing in a blocking JDBC call defeats the purpose and can starve the event loop |
Learning curve / debugging |
Familiar imperative code, straightforward stack traces |
Functional/declarative operator chains, steeper learning curve, harder-to-read stack traces |
Downstream calls |
|
|
A good rule of thumb: default to Spring MVC unless the service is dominated by slow, concurrent I/O to other
services and every layer of the stack (database driver, HTTP client, downstream calls) can realistically stay
non-blocking. Adding WebFlux on top of a data-access layer that still blocks (a JDBC DataSource, a blocking
JPA repository) usually removes the benefit while adding all of the complexity. For the deeper operator-level
treatment of Mono/Flux, backpressure, and Reactor schedulers, see
Reactive Programming.
See Spring Boot’s reactive web applications reference for how Spring Boot auto-configures Netty/WebFlux, and the general servlet web applications reference for the servlet-side equivalents referenced throughout this page.
Cross-origin requests (CORS)
A browser blocks JavaScript running on https://app.example.com from reading the response of a fetch() to
https://api.example.com unless the API opts in with Cross-Origin Resource Sharing response headers. Spring
MVC and WebFlux both implement the server side of that protocol. This section covers how to enable it and the
traps that make CORS a recurring source of vulnerabilities; for the protocol itself — the Origin header,
"simple" vs. preflighted (OPTIONS) requests, and the response headers — see What is CORS?.
@CrossOrigin on a controller
@CrossOrigin declares a policy for a whole controller or a single handler method:
@RestController
@RequestMapping("/api/orders")
@CrossOrigin(origins = "https://app.example.com") // applies to every handler below
public class OrderController {
@GetMapping("/{id}")
public OrderView get(@PathVariable Long id) { /* ... */ }
@PostMapping
@CrossOrigin( // narrower override for one handler
origins = { "https://app.example.com", "https://admin.example.com" },
methods = RequestMethod.POST,
allowedHeaders = { "Content-Type", "Authorization" },
maxAge = 600)
public ResponseEntity<Void> create(@Valid @RequestBody CreateOrder body) { /* ... */ }
}
Key attributes: origins (exact scheme://host:port matches), originPatterns (wildcard matching such as
https://*.example.com), methods (defaults to the methods the handler maps), allowedHeaders,
exposedHeaders (response headers the browser lets script read), allowCredentials (whether the browser may
send cookies / HTTP authentication), and maxAge (how long the browser caches the preflight result).
Prefer one global policy
Scattering @CrossOrigin across controllers makes the effective policy hard to see and easy to make
inconsistent. Define it once instead, reading the values from configuration so each environment sets its own:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
private final CorsProperties props; // @ConfigurationProperties("app.cors")
public CorsConfig(CorsProperties props) {
this.props = props;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(props.allowedOrigins().toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAge(600);
}
}
When Spring Security is on the classpath it must also be told to honor CORS — http.cors(Customizer.withDefaults())
plus a CorsConfigurationSource bean — otherwise the security filter chain rejects the preflight before it
reaches Spring MVC. On WebFlux, use CorsWebFilter or WebFluxConfigurer#addCorsMappings.
Why @CrossOrigin is dangerous
-
origins = "" exposes the API to every site on the internet.* Any page a victim visits can then make browser requests to the API and read the responses. Only wildcard an endpoint that is genuinely public and unauthenticated. -
"" together with credentials is invalid — and the usual workaround is worse.* The specification forbidsAccess-Control-Allow-Origin:withAccess-Control-Allow-Credentials: true. Switching tooriginPatterns = ""(orallowedOriginPatterns("")) makes Spring *reflect the caller’sOriginheader back and still send credentials, so any website can issue authenticated, cookie-bearing requests and read the result — a cross-site data leak. Never combine credentialed CORS with a wildcard or a reflected origin; list exact origins. -
Over-broad patterns.
https://*.example.comalso trustshttps://forgotten-marketing-site.example.comand any subdomain vulnerable to takeover; one attacker-controlled subdomain becomes a trusted origin. Enumerate origins explicitly wherever you can. -
Policy drift. A
@CrossOriginon a controller can silently disagree with the Spring Security CORS configuration, or with an API gateway in front of the app. Keep a single source of truth. -
CORS is not a control on the request itself. It only governs whether a browser lets script read the response. The request still reaches the handler and any side effect still runs — especially for non-preflighted "simple" requests. CORS does not authenticate anyone, does not replace CSRF protection, and does nothing for non-browser clients. See What is CORS? ("CORS Does Not Replace Authentication").
-
Leaking headers.
allowedHeaders = "*"with a broadexposedHeaderscan give cross-origin script access to response headers you did not intend to share.
Safe defaults: an explicit, environment-specific allowlist of origins; allowCredentials(true) only alongside
that exact list (never a pattern); the narrowest allowedMethods / allowedHeaders the client actually needs;
and the policy defined once, next to the security configuration.
See Spring Framework — CORS and its
WebFlux equivalent, the
@CrossOrigin
API documentation, and
Spring Security — CORS.
gRPC as an alternative
REST (over HTTP/JSON) is not the only option for service-to-service APIs on Spring Boot. For contract-first, binary, HTTP/2-native RPC between services, see gRPC APIs.