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

RestClient / RestTemplate (blocking)

WebClient (non-blocking, composes with Mono/Flux)

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 forbids Access-Control-Allow-Origin: with Access-Control-Allow-Credentials: true. Switching to originPatterns = "" (or allowedOriginPatterns("")) makes Spring *reflect the caller’s Origin header 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.com also trusts https://forgotten-marketing-site.example.com and any subdomain vulnerable to takeover; one attacker-controlled subdomain becomes a trusted origin. Enumerate origins explicitly wherever you can.

  • Policy drift. A @CrossOrigin on 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 broad exposedHeaders can 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.

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.