skip to content

Annotated Reactive Controllers

The same annotations as MVC, but returning Mono<T> or Flux<T> so nothing blocks while the value is produced. Interviewers ask what actually changes — and what does not — when you swap the return type.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

questions

5

How do you write an annotated reactive controller in Spring WebFlux, and what changes compared to a Spring MVC controller?

level: juniorimportance: must knowfreq 75%

answer

  1. Same annotations as MVC
  2. Return Mono (0-1) / Flux (0-N)
  3. Framework subscribes for you
  4. Empty Mono = 200 empty, not 404
  5. Never .block() on event loop

basics

~10 s

Use the same annotations as MVC (@RestController, @GetMapping, @PathVariable), but return reactive types: Mono<T> for zero-or-one value and Flux<T> for a stream of values, instead of a plain object.

solid answer

~40 s

WebFlux reuses the exact same annotation programming model as MVC: @RestController, @GetMapping/@PostMapping, @PathVariable, @RequestParam, @RequestBody. The one real change is the return type — instead of returning a plain object (or void), you return a Reactor publisher: Mono<T> for a single or empty result, Flux<T> for many. The framework subscribes to that publisher for you and writes the result to the response without blocking a thread. You can still return a plain T (it gets wrapped) or wrap in ResponseEntity. Because everything runs on a small event-loop thread pool (Reactor Netty), the golden rule is: never call .block() or do blocking I/O inside the method — compose everything through the reactive pipeline instead. Same mental model, non-blocking return types.

code

java · 20 lines
java
@RestController
@RequestMapping("/users")
class UserController {

    private final UserRepository repo; // reactive: returns Mono/Flux

    UserController(UserRepository repo) { this.repo = repo; }

    @GetMapping("/{id}")
    Mono<User> byId(@PathVariable String id) {
        return repo.findById(id)
                   .switchIfEmpty(Mono.error(
                       new ResponseStatusException(HttpStatus.NOT_FOUND)));
    }

    @GetMapping
    Flux<User> all() {
        return repo.findAll();
    }
}

go deeper

for a junior

Know that the annotations are the same and only the return type becomes Mono/Flux.

for a middle

Explain the empty-Mono 200-vs-404 gotcha and why blocking is forbidden on the event loop.

for a senior

Discuss offloading blocking work with boundedElastic and when MVC is the better call.

for a principal

Frame the choice around the whole-stack reactive requirement and concurrency/thread-economy trade-offs.

## The core idea Spring WebFlux is Spring's non-blocking, reactive web stack. Its biggest selling point for developers is that the **annotated-controller programming model is identical to Spring MVC**. You use `@RestController`, `@Controller`, `@RequestMapping`, `@GetMapping`, `@PostMapping`, `@PathVariable`, `@RequestParam`, `@RequestBody`, `@RequestHeader`, `@ResponseStatus`, `@ExceptionHandler` — the same annotations, resolved by the same `HandlerMapping`/`HandlerAdapter` abstractions. ## What actually changes: the return type In MVC, a handler returns a plain value like `User` or `List<User>`, and the framework blocks the request thread until you produce it. In WebFlux you return a **Reactive Streams `Publisher`**, almost always one of Project Reactor's two types: - **`Mono<T>`** — a publisher that emits **0 or 1** item then completes (or errors). Use for a single resource, an empty result, or a `void`-like operation (`Mono<Void>`). - **`Flux<T>`** — a publisher that emits **0..N** items then completes (or errors). Use for collections/streams. The framework **subscribes** to whatever you return, and only when data actually arrives does it get written to the HTTP response. Nothing runs on a blocked thread waiting. ## Terms defined - **Reactive / non-blocking**: instead of a thread parking while it waits for I/O (DB, HTTP call), the thread is released and a callback resumes work when data is ready. A handful of event-loop threads serve many concurrent requests. - **Publisher / subscribe**: a `Publisher` produces nothing until something *subscribes*. In annotated WebFlux controllers, **Spring subscribes for you** — you never call `.subscribe()` yourself. - **Reactor Netty**: the default runtime server; it uses a small fixed set of event-loop threads. ## A minimal example ```java @RestController @RequestMapping("/users") class UserController { private final UserRepository repo; // reactive repo returns Mono/Flux @GetMapping("/{id}") Mono<User> byId(@PathVariable String id) { return repo.findById(id); // Mono<User>, may be empty } @GetMapping Flux<User> all() { return repo.findAll(); // Flux<User> } } ``` ## Allowed return shapes - `Mono<T>` / `Flux<T>` (Reactor) — the idiomatic choice. - Any Reactive Streams `Publisher`, RxJava types, Kotlin `Flow`, or coroutine `suspend` functions. - A **plain `T`** — Spring wraps it; fine when the value is computed synchronously and cheaply. - `Mono<ResponseEntity<T>>` or `ResponseEntity<Mono<T>>` when you need to set status/headers. ## Empty-result edge case An empty `Mono<User>` (repository found nothing) results in a **200 OK with an empty body** by default — NOT a 404. To turn 'not found' into a 404 you must handle it explicitly, e.g. `.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND)))` or return `Mono<ResponseEntity<T>>` mapping empty to `notFound()`. ## The one hard rule Because only a few event-loop threads exist, **blocking one stalls many requests**. Never call `.block()`, `Thread.sleep`, JDBC, or any blocking library directly inside a WebFlux controller. If you must call blocking code, offload it with `Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())`. ## When to use WebFlux annotated controllers shine when the whole stack is non-blocking (R2DBC, WebClient) and you need high concurrency with few threads or streaming responses. If your dependencies are blocking JDBC, plain MVC is usually simpler and just as fast.

  • If your repository is blocking JDBC, does returning Mono/Flux from the controller make it non-blocking?
    No. Wrapping a blocking call in a Mono doesn't make it non-blocking — the blocking happens when it runs. You'd have to offload it to Schedulers.boundedElastic(), or use a truly reactive driver like R2DBC. A blocking driver behind WebFlux gives you the worst of both worlds.
  • What happens if a @GetMapping returns an empty Mono?
    The client gets a 200 OK with an empty body by default, not a 404. To signal 'not found' you must switchIfEmpty to an error/ResponseStatusException or map to ResponseEntity.notFound().

saying these in an interview costs you the question

  • Thinking WebFlux needs completely different annotations from MVC
  • Believing wrapping blocking code in Mono makes it non-blocking
  • Assuming an empty Mono automatically produces a 404
  • Calling .subscribe() manually inside the controller method

context

open as a page

How do @PathVariable and @RequestParam behave in a WebFlux annotated controller — are they reactive, and how do you bind optional or multi-valued query params?

level: middleimportance: should knowfreq 45%

basics

~20 s

They work exactly like in MVC and are plain (non-reactive) values — the URI and query string are already available, so you bind String/int directly. Use required=false or Optional for optional params, and List<T> for multi-valued ones.

open as a page

What is the difference between declaring @RequestBody User user and @RequestBody Mono<User> user in a WebFlux handler?

level: middleimportance: should knowfreq 55%

basics

~20 s

@RequestBody User fully decodes the body before the method runs. @RequestBody Mono<User> hands you a publisher of the body — decoding is deferred until you subscribe, so you compose the request body into your reactive pipeline.

open as a page

You return Flux<User> from a @GetMapping. How is it serialized, and how do you make the endpoint actually stream results to the client instead of sending one JSON array?

level: seniorimportance: should knowfreq 50%

basics

~10 s

By default Flux<User> is serialized as a single JSON array (application/json). To stream element-by-element, set produces to text/event-stream (SSE) or application/x-ndjson, so each item is flushed as it is emitted.

open as a page

In an annotated WebFlux controller, who subscribes to the returned publisher, where is the threading danger, and how do you return proper HTTP status codes and handle errors reactively?

level: principalimportance: should knowfreq 40%

basics

~10 s

The framework subscribes to your Mono/Flux on the event-loop thread — so never block it. For status/errors, use ResponseEntity or ResponseStatusException, compose errors with onErrorResume/switchIfEmpty, and centralize with @ExceptionHandler; offload blocking work to boundedElastic.

open as a page