How do you write an annotated reactive controller in Spring WebFlux, and what changes compared to a Spring MVC controller?
answer
- Same annotations as MVC
- Return Mono (0-1) / Flux (0-N)
- Framework subscribes for you
- Empty Mono = 200 empty, not 404
- Never .block() on event loop
basics
~10 sUse 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 sWebFlux 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@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
Know that the annotations are the same and only the return type becomes Mono/Flux.
Explain the empty-Mono 200-vs-404 gotcha and why blocking is forbidden on the event loop.
Discuss offloading blocking work with boundedElastic and when MVC is the better call.
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