Reactive REST APIs
With Spring WebFlux and Project Reactor you can build fully non-blocking REST APIs: every layer — controller, service, data access, downstream HTTP — returns a Mono or Flux, and no thread ever waits on I/O. This page walks through a CRUD resource, streaming responses, calling other services, and handling errors reactively.
A non-blocking CRUD endpoint
Each handler returns a publisher. The service delegates to a reactive repository (see R2DBC) that itself returns Mono/Flux, so the whole chain is non-blocking end to end.
@RestController
@RequestMapping("/api/products")
class ProductController {
private final ProductService service;
ProductController(ProductService service) {
this.service = service;
}
@GetMapping
Flux<Product> findAll() {
return service.findAll();
}
@GetMapping("/{id}")
Mono<ResponseEntity<Product>> findById(@PathVariable String id) {
return service.findById(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Mono<Product> create(@Valid @RequestBody Mono<ProductRequest> body) {
return body.flatMap(service::create);
}
@PutMapping("/{id}")
Mono<ResponseEntity<Product>> update(@PathVariable String id,
@Valid @RequestBody Mono<ProductRequest> body) {
return body.flatMap(req -> service.update(id, req))
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
Mono<Void> delete(@PathVariable String id) {
return service.delete(id);
}
}
DTOs are still plain records with validation constraints; @Valid works on the deserialized body.
public record ProductRequest(
@NotBlank String name,
@Positive BigDecimal price) { }
Streaming with Server-Sent Events
The real superpower of Flux over the wire is streaming. By producing text/event-stream, the server pushes items to the client one at a time as they are produced — no buffering the whole collection in memory. This is ideal for live feeds, progress updates, or large result sets.
@GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<PriceTick> streamPrices() {
return Flux.interval(Duration.ofSeconds(1))
.map(seq -> new PriceTick("BTC", 64000 + seq));
}
Output: the connection stays open and the client receives events as they arrive:
data:{"symbol":"BTC","price":64000}
data:{"symbol":"BTC","price":64001}
data:{"symbol":"BTC","price":64002}
Backpressure protects the server: if the client reads slowly, Reactor slows the source rather than piling up memory. A plain Flux<Product> returned as JSON, by contrast, is buffered into a single [ ... ] array.
Calling downstream services with WebClient
In a reactive app you must use a non-blocking HTTP client. That is WebClient (never the blocking RestTemplate). It returns Mono/Flux, so downstream calls slot straight into your pipeline.
@Service
class InventoryService {
private final WebClient client;
InventoryService(WebClient.Builder builder) {
this.client = builder.baseUrl("https://inventory.internal").build();
}
Mono<Stock> stockFor(String productId) {
return client.get()
.uri("/stock/{id}", productId)
.retrieve()
.bodyToMono(Stock.class)
.timeout(Duration.ofSeconds(2))
.onErrorResume(ex -> Mono.just(Stock.unknown(productId)));
}
}
Combine an inventory lookup with the product fetch concurrently using zip:
Mono<ProductView> view = Mono.zip(
service.findById(id),
inventory.stockFor(id))
.map(t -> new ProductView(t.getT1(), t.getT2()));
See WebClient for the full client API, headers, and auth.
Error handling
You have two complementary tools: operators inside the pipeline and a centralized @RestControllerAdvice.
Inline with operators
service.findById(id)
.switchIfEmpty(Mono.error(new ProductNotFoundException(id)))
.onErrorResume(DownstreamUnavailable.class,
ex -> Mono.just(Product.placeholder(id)));
Centralized with @ExceptionHandler
@RestControllerAdvice works in WebFlux just as in MVC. Exceptions signalled anywhere in the reactive chain are routed here.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
pd.setTitle("Product not found");
pd.setDetail(ex.getMessage());
return pd;
}
}
Output:
{
"type": "about:blank",
"title": "Product not found",
"status": 404,
"detail": "No product with id 42"
}
Tip: Return
ProblemDetail(RFC 9457) for consistent, machine-readable error bodies across both reactive and blocking APIs.
Warning: Never call
.block()inside a controller or service to “simplify” things — it blocks a Netty event loop thread and undermines the entire reactive stack. Keep the pipeline reactive all the way down.
Related Topics
- Spring WebFlux — the framework these endpoints run on.
- Mono & Flux — the operators used throughout.
- WebClient — the non-blocking HTTP client.
- R2DBC (Reactive SQL) — reactive persistence behind the service.
- Server-Sent Events — streaming pushes to the browser.
- Building REST APIs — the blocking MVC equivalent.