Spring Boot 3 Reactive WebFlux and WebClient

Reactive programming is revolutionizing how we build scalable and performant applications in Java. With Spring Boot 3 and Spring WebFlux, creating reactive, non-blocking apps has never been easier.

Sopheaktra

Eang Sopheaktra

May 24 2025 12:01 am

0 73

Why Reactive?

Traditional blocking HTTP calls can slow down applications and limit throughput. Reactive programming embraces asynchronous, event-driven architectures to handle many concurrent requests with fewer resources. Spring WebFlux is Spring’s reactive web framework built on Project Reactor.

Why Use Micrometer Tracing?

Distributed tracing is essential for understanding behavior across services. Micrometer tracing with Brave lets you track reactive calls end-to-end with minimal config.

What You’ll Build

A simple, clean Spring Boot 3 application that demonstrates:

  • Reactive HTTP client calls using WebClient.

  • Integration with Micrometer tracing using Brave for distributed tracing.

  • Boilerplate reduction with Lombok.

  • Reactive testing using Reactor Test and JUnit 5.

Note: This example focuses on the client side — no web server endpoints are exposed here, but adding them is easy if you want.

Project Setup

The project uses:

  • Java 17 with Gradle.

  • spring-boot-starter-webflux for WebClient and reactive support.

  • micrometer-tracing-bridge-brave to enable tracing with Brave.

  • Lombok to reduce boilerplate.

  • Spring Boot’s test starter and Reactor Test for unit testing.

Here’s the core dependency you need to get reactive WebClient support:

implementation 'org.springframework.boot:spring-boot-starter-webflux'

Reactive WebClient in Action

First thing to do is create configuration properties to handle config of fake api

package com.webflux.demo.configurations.properties;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Getter
@Setter
@Component("fakeAPIHttpClientProperty")
@ConfigurationProperties(prefix = "client.fake.api")
public class FakeAPIHttpClientProperty {
    private String url;
}

Then we should create HttpClient bean for handle timeout like below:

    @Bean
    public HttpClient httpClient() {
        return HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
                .doOnConnected(conn -> conn
                        .addHandlerLast(new ReadTimeoutHandler(10))
                        .addHandlerLast(new WriteTimeoutHandler(10))
                );
    }

Also on this tutorial I will using webclient so need to create bean of webclient and have 2 bean:

  • Primary I called defaultWebClient
  • Fake API I called fakeAPIWebClient

Here is sample code:

 @Bean
    @Primary
    public WebClient defaultWebClient() {
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(httpClient()))
                .build();
    }

    @Bean("fakeAPIWebClient")
    public WebClient fakeAPIWebClient() {
        return WebClient.builder()
                .baseUrl(fakeAPIHttpClientProperty.getUrl())
                .clientConnector(new ReactorClientHttpConnector(httpClient()))
                .defaultHeader("User-Agent", "MyApp/1.0")
                .filter(enrichContextWithRequest())
                .filter(logRequest())
                .filter(addTraceIdToContext())
                .filter(logResponse())
                .build();
    }

For testing I also create service and controller like below example code:

Service:

package com.webflux.demo.services.clients;

import com.webflux.demo.payloads.clients.FakeAPI.Cart;
import com.webflux.demo.payloads.clients.FakeAPI.LoginRequest;
import com.webflux.demo.payloads.clients.FakeAPI.Product;
import com.webflux.demo.payloads.clients.FakeAPI.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Service
@Slf4j
public class FakeStoreClient {
    private final WebClient fakeAPIWebClient;

    public FakeStoreClient(@Qualifier("fakeAPIWebClient") WebClient fakeAPIWebClient) {
        this.fakeAPIWebClient = fakeAPIWebClient;
    }

    // === Products ===
    public Flux<Product> getAllProducts() {
        return fakeAPIWebClient.get()
                .uri("/products")
                .retrieve()
                .bodyToFlux(Product.class);
    }

    public Mono<Product> getProductById(int id) {
        return fakeAPIWebClient.get()
                .uri("/products/{id}", id)
                .retrieve()
                .bodyToMono(Product.class);
    }

    public Flux<String> getAllCategories() {
        return fakeAPIWebClient.get()
                .uri("/products/categories")
                .retrieve()
                .bodyToFlux(String.class);
    }

    public Flux<Product> getProductsByCategory(String category) {
        return fakeAPIWebClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/products/category/{category}")
                        .build(category))
                .retrieve()
                .bodyToFlux(Product.class);
    }

    public Flux<Product> getLimitedProducts(int limit) {
        return fakeAPIWebClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/products")
                        .queryParam("limit", limit)
                        .build())
                .retrieve()
                .bodyToFlux(Product.class);
    }

    public Mono<Product> addProduct(Product product) {
        return fakeAPIWebClient.post()
                .uri("/products")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(product)
                .retrieve()
                .bodyToMono(Product.class);
    }

    public Mono<Product> updateProduct(int id, Product product) {
        return fakeAPIWebClient.put()
                .uri("/products/{id}", id)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(product)
                .retrieve()
                .bodyToMono(Product.class);
    }

    public Mono<Void> deleteProduct(int id) {
        return fakeAPIWebClient.delete()
                .uri("/products/{id}", id)
                .retrieve()
                .bodyToMono(Void.class);
    }

    // === Carts ===

    public Flux<Cart> getAllCarts() {
        return fakeAPIWebClient.get()
                .uri("/carts")
                .retrieve()
                .bodyToFlux(Cart.class);
    }

    public Mono<Cart> getCartById(int id) {
        return fakeAPIWebClient.get()
                .uri("/carts/{id}", id)
                .retrieve()
                .bodyToMono(Cart.class);
    }

    public Flux<Cart> getCartsByUserId(int userId) {
        return fakeAPIWebClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/carts/user/{userId}")
                        .build(userId))
                .retrieve()
                .bodyToFlux(Cart.class);
    }

    public Mono<Cart> addCart(Cart cart) {
        return fakeAPIWebClient.post()
                .uri("/carts")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(cart)
                .retrieve()
                .bodyToMono(Cart.class);
    }

    public Mono<Cart> updateCart(int id, Cart cart) {
        return fakeAPIWebClient.put()
                .uri("/carts/{id}", id)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(cart)
                .retrieve()
                .bodyToMono(Cart.class);
    }

    public Mono<Void> deleteCart(int id) {
        return fakeAPIWebClient.delete()
                .uri("/carts/{id}", id)
                .retrieve()
                .bodyToMono(Void.class);
    }

    // === Users ===

    public Flux<User> getAllUsers() {
        return fakeAPIWebClient.get()
                .uri("/users")
                .retrieve()
                .bodyToFlux(User.class);
    }

    public Mono<User> getUserById(int id) {
        return fakeAPIWebClient.get()
                .uri("/users/{id}", id)
                .retrieve()
                .bodyToMono(User.class);
    }

    public Mono<User> addUser(User user) {
        return fakeAPIWebClient.post()
                .uri("/users")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(user)
                .retrieve()
                .bodyToMono(User.class);
    }

    public Mono<User> updateUser(int id, User user) {
        return fakeAPIWebClient.put()
                .uri("/users/{id}", id)
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(user)
                .retrieve()
                .bodyToMono(User.class);
    }

    public Mono<Void> deleteUser(int id) {
        return fakeAPIWebClient.delete()
                .uri("/users/{id}", id)
                .retrieve()
                .bodyToMono(Void.class);
    }

    // === Auth (Login) ===
    public Mono<User> loginUser(String username, String password) {
        return fakeAPIWebClient.post()
                .uri("/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(new LoginRequest(username, password))
                .retrieve()
                .bodyToMono(User.class);
    }
}

Controller:

package com.webflux.demo.controllers;

import com.webflux.demo.payloads.clients.ApiResponse;
import com.webflux.demo.payloads.clients.FakeAPI.Cart;
import com.webflux.demo.payloads.clients.FakeAPI.LoginRequest;
import com.webflux.demo.payloads.clients.FakeAPI.Product;
import com.webflux.demo.payloads.clients.FakeAPI.User;
import com.webflux.demo.services.clients.FakeStoreClient;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/fake-api")
@RequiredArgsConstructor
public class FakeAPIController {
    private final FakeStoreClient fakeStoreClient;

    // === Products ===
    @GetMapping("/products")
    public Flux<ApiResponse<Product>> getAllProducts() {
        return fakeStoreClient.getAllProducts()
                .map(ApiResponse::ok);
    }

    @GetMapping("/products/{id}")
    public Mono<ApiResponse<Product>> getProductById(@PathVariable int id) {
        return fakeStoreClient.getProductById(id)
                .map(ApiResponse::ok);
    }

    @GetMapping("/products/categories")
    public Flux<ApiResponse<String>> getAllCategories() {
        return fakeStoreClient.getAllCategories()
                .map(ApiResponse::ok);
    }

    @GetMapping("/products/category/{category}")
    public Flux<ApiResponse<Product>> getProductsByCategory(@PathVariable String category) {
        return fakeStoreClient.getProductsByCategory(category)
                .map(ApiResponse::ok);
    }

    @GetMapping("/products/limited")
    public Flux<ApiResponse<Product>> getLimitedProducts(@RequestParam int limit) {
        return fakeStoreClient.getLimitedProducts(limit)
                .map(ApiResponse::ok);
    }

    @PostMapping(value = "/products", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<Product>> addProduct(@RequestBody Product product) {
        return fakeStoreClient.addProduct(product)
                .map(ApiResponse::ok);
    }

    @PutMapping(value = "/products/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<Product>> updateProduct(@PathVariable int id, @RequestBody Product product) {
        return fakeStoreClient.updateProduct(id, product)
                .map(ApiResponse::ok);
    }

    @DeleteMapping("/products/{id}")
    public Mono<ApiResponse<Void>> deleteProduct(@PathVariable int id) {
        return fakeStoreClient.deleteProduct(id)
                .thenReturn(ApiResponse.ok(null));
    }

    // === Carts ===
    @GetMapping("/carts")
    public Flux<ApiResponse<Cart>> getAllCarts() {
        return fakeStoreClient.getAllCarts()
                .map(ApiResponse::ok);
    }

    @GetMapping("/carts/{id}")
    public Mono<ApiResponse<Cart>> getCartById(@PathVariable int id) {
        return fakeStoreClient.getCartById(id)
                .map(ApiResponse::ok);
    }

    @GetMapping("/carts/user/{userId}")
    public Flux<ApiResponse<Cart>> getCartsByUserId(@PathVariable int userId) {
        return fakeStoreClient.getCartsByUserId(userId)
                .map(ApiResponse::ok);
    }

    @PostMapping(value = "/carts", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<Cart>> addCart(@RequestBody Cart cart) {
        return fakeStoreClient.addCart(cart)
                .map(ApiResponse::ok);
    }

    @PutMapping(value = "/carts/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<Cart>> updateCart(@PathVariable int id, @RequestBody Cart cart) {
        return fakeStoreClient.updateCart(id, cart)
                .map(ApiResponse::ok);
    }

    @DeleteMapping("/carts/{id}")
    public Mono<ApiResponse<Void>> deleteCart(@PathVariable int id) {
        return fakeStoreClient.deleteCart(id)
                .thenReturn(ApiResponse.ok(null));
    }

    // === Users ===
    @GetMapping("/users")
    public Flux<ApiResponse<User>> getAllUsers() {
        return fakeStoreClient.getAllUsers()
                .map(ApiResponse::ok);
    }

    @GetMapping("/users/{id}")
    public Mono<ApiResponse<User>> getUserById(@PathVariable int id) {
        return fakeStoreClient.getUserById(id)
                .map(ApiResponse::ok);
    }

    @PostMapping(value = "/users", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<User>> addUser(@RequestBody User user) {
        return fakeStoreClient.addUser(user)
                .map(ApiResponse::ok);
    }

    @PutMapping(value = "/users/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<User>> updateUser(@PathVariable int id, @RequestBody User user) {
        return fakeStoreClient.updateUser(id, user)
                .map(ApiResponse::ok);
    }

    @DeleteMapping("/users/{id}")
    public Mono<ApiResponse<Void>> deleteUser(@PathVariable int id) {
        return fakeStoreClient.deleteUser(id)
                .thenReturn(ApiResponse.ok(null));
    }

    // === Auth (Login) ===
    @PostMapping(value = "/auth/login", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Mono<ApiResponse<User>> loginUser(@RequestBody LoginRequest loginRequest) {
        return fakeStoreClient.loginUser(loginRequest.getUsername(), loginRequest.getPassword())
                .map(ApiResponse::ok);
    }
}

Cool it's done. We can follow how to test this project such as next step.

Running the Project

Simply clone the repo, build, and run:

git clone https://github.com/yourusername/spring-boot-3-reactive-webflux.git
cd spring-boot-3-reactive-webflux
./gradlew clean build
./gradlew bootRun

Since this is a WebClient-focused client app, it doesn’t start an HTTP server but runs your reactive code.

Summary

Spring Boot 3 with WebFlux and WebClient offers a modern, reactive foundation for building scalable, efficient apps. This minimal project is a great starting point for anyone looking to adopt reactive programming in Java with the latest Spring ecosystem. 

Source Code: Github's Repo

 

This post is not allow comment!

Subscribe to our newsletter.

Get updates about new articles, contents, coding tips and tricks.

Weekly articles
Always new release articles every week or weekly released.
No spam
No marketing, share and spam you different notification.
© 2023-2025 Tra21, Inc. All rights reserved.