Course topics

By WebNest Studio

Spring Boot Tutorial

Building Reactive APIs with Spring WebFlux

Spring WebFlux is Spring's reactive web framework, the non-blocking counterpart of Spring MVC. It runs on Netty by default (or on Servlet containers in non-blocking mode) and handles requests with a small number of event-loop threads, returning Mono and Flux instead of plain objects.

This lesson builds reactive REST endpoints with both programming models — familiar annotated controllers and functional router functions — streams data to browsers with Server-Sent Events, handles errors and validation, and tests endpoints with WebTestClient.

Setup

Add spring-boot-starter-webflux instead of spring-boot-starter-webmvc. If both are present, Spring Boot starts Spring MVC, so choose one per application. Spring Boot auto-configures Reactor Netty, Jackson codecs, validation and error handling. The whole call chain must be non-blocking to benefit, so pair WebFlux with reactive data access (R2DBC, reactive MongoDB or Redis) and WebClient.

Annotated Controllers

The same annotations as Spring MVC work in WebFlux: @RestController, @GetMapping, @RequestBody, @PathVariable, @Valid, @ExceptionHandler. The difference is the return types: Mono<T> for single values and Flux<T> for collections or streams, and request bodies can be Mono<T> too. This makes moving between the stacks easy for developers.

Functional Endpoints

Alternatively, define routes as code with RouterFunction and handler functions that take a ServerRequest and return Mono<ServerResponse>. Functional endpoints give you explicit control over routing and are easy to compose, which some teams prefer for small services and gateways.

Streaming with Server-Sent Events

Returning a Flux with produces = MediaType.TEXT_EVENT_STREAM_VALUE keeps the connection open and pushes each element to the client as an SSE event — ideal for live prices, progress updates, notifications and streaming AI answers. Browsers consume SSE with the built-in EventSource API.

Testing with WebTestClient

@WebFluxTest loads the web slice for WebFlux (add spring-boot-starter-webflux-test), and WebTestClient sends requests and asserts on status, headers, JSON bodies and even streams. With @SpringBootTest(webEnvironment = RANDOM_PORT) it tests a running server.

Examples

An annotated reactive controller

Java
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

@RestController
@RequestMapping("/api/courses")
public class CourseController {

    private final CourseRepository courses;     // reactive repository (R2DBC)

    public CourseController(CourseRepository courses) {
        this.courses = courses;
    }

    @GetMapping
    public Flux<Course> all() {
        return courses.findAll();
    }

    @GetMapping("/{id}")
    public Mono<ResponseEntity<Course>> one(@PathVariable Long id) {
        return courses.findById(id)
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<Course> create(@Valid @RequestBody Mono<NewCourse> body) {
        return body.map(NewCourse::toEntity).flatMap(courses::save);
    }
}
Output
Netty started on port 8080 (http)
GET  /api/courses      -> [{"id":1,"slug":"java-core",...},{"id":2,"slug":"spring-boot",...}]
GET  /api/courses/99   -> 404
POST /api/courses {...} -> 201 {"id":3,...}

Functional endpoints with RouterFunction

Java
@Configuration
public class StudentRoutes {

    @Bean
    RouterFunction<ServerResponse> studentRouter(StudentHandler handler) {
        return RouterFunctions.route()
            .path("/api/students", builder -> builder
                .GET("", handler::list)
                .GET("/{id}", handler::get)
                .POST("", handler::create))
            .build();
    }
}

@Component
public class StudentHandler {

    private final StudentRepository students;

    public StudentHandler(StudentRepository students) {
        this.students = students;
    }

    public Mono<ServerResponse> list(ServerRequest req) {
        return ServerResponse.ok().body(students.findAll(), Student.class);
    }

    public Mono<ServerResponse> get(ServerRequest req) {
        long id = Long.parseLong(req.pathVariable("id"));
        return students.findById(id)
            .flatMap(s -> ServerResponse.ok().bodyValue(s))
            .switchIfEmpty(ServerResponse.notFound().build());
    }

    public Mono<ServerResponse> create(ServerRequest req) {
        return req.bodyToMono(Student.class)
            .flatMap(students::save)
            .flatMap(saved -> ServerResponse.created(URI.create("/api/students/" + saved.id())).bodyValue(saved));
    }
}
Output
GET /api/students/1 -> 200 {"id":1,"name":"Asha Rao"}
GET /api/students/9 -> 404

Streaming live updates with Server-Sent Events

Java
public record PriceTick(String symbol, BigDecimal price, Instant at) {}

@RestController
public class LivePriceController {

    @GetMapping(value = "/api/prices/{symbol}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<PriceTick> stream(@PathVariable String symbol) {
        return Flux.interval(Duration.ofSeconds(1))
            .map(i -> new PriceTick(symbol, randomPrice(), Instant.now()))
            .take(Duration.ofMinutes(5));
    }
}

// Browser
// const es = new EventSource('/api/prices/WEBNEST/stream');
// es.onmessage = e => console.log(JSON.parse(e.data));
Output
curl -N localhost:8080/api/prices/WEBNEST/stream
data:{"symbol":"WEBNEST","price":102.35,"at":"2026-09-27T10:00:01Z"}

data:{"symbol":"WEBNEST","price":102.41,"at":"2026-09-27T10:00:02Z"}
...

Testing with @WebFluxTest and WebTestClient

Java
@WebFluxTest(CourseController.class)
class CourseControllerTest {

    @Autowired WebTestClient client;
    @MockitoBean CourseRepository courses;

    @Test
    void returnsCourse() {
        when(courses.findById(1L)).thenReturn(Mono.just(new Course(1L, "spring-boot", "Spring Boot")));

        client.get().uri("/api/courses/1")
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.slug").isEqualTo("spring-boot");
    }

    @Test
    void returns404WhenMissing() {
        when(courses.findById(9L)).thenReturn(Mono.empty());

        client.get().uri("/api/courses/9").exchange().expectStatus().isNotFound();
    }
}
Output
CourseControllerTest
  ✔ returnsCourse()
  ✔ returns404WhenMissing()

Common Mistakes

  • Adding both webmvc and webflux starters and expecting WebFlux — Spring Boot then runs Spring MVC.
  • Using JPA/JDBC repositories in WebFlux handlers, blocking the event loop.
  • Returning Mono<List<T>> for large collections instead of Flux<T>, losing streaming and backpressure.
  • Putting blocking code in RouterFunction handlers without moving it to boundedElastic.
  • Forgetting that SecurityContext is read from ReactiveSecurityContextHolder, not SecurityContextHolder.

Key Points to Remember

  • spring-boot-starter-webflux runs on Reactor Netty with non-blocking request handling.
  • Annotated controllers return Mono and Flux; functional endpoints use RouterFunction and handlers.
  • Flux with text/event-stream streams Server-Sent Events to browsers.
  • The full chain must be non-blocking: use R2DBC, reactive drivers and WebClient.
  • Test with @WebFluxTest and WebTestClient.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.