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.
Eang Sopheaktra
May 24 2025 12:01 am
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.
Distributed tracing is essential for understanding behavior across services. Micrometer tracing with Brave lets you track reactive calls end-to-end with minimal config.
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.
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'
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:
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.
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.
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