Backend

Building Reactive APIs with Spring WebFlux

Building Reactive APIs with Spring WebFlux

Introduction

As applications scale, traditional thread-per-request servers struggle under heavy load. This is especially true for I/O-heavy systems, API aggregators, and microservices that depend on multiple downstream calls. To solve this, the Spring team created Spring WebFlux, a reactive, non-blocking alternative to Spring MVC. Built on Project Reactor, WebFlux uses reactive streams to process data asynchronously, allowing your APIs to handle thousands of concurrent connections with minimal threads. In this comprehensive guide, you’ll learn the fundamentals of reactive programming, build complete CRUD APIs with WebFlux, and implement patterns like error handling, WebClient integration, and R2DBC database access.

What Is Spring WebFlux?

Spring WebFlux is a framework for building reactive, non-blocking web applications. It’s built on top of Project Reactor, which implements the Reactive Streams specification. Instead of blocking threads while waiting for external calls, WebFlux returns Publishers that produce results asynchronously.

Key Concepts

  • Mono<T> – Represents 0 or 1 element asynchronously
  • Flux<T> – Represents 0 to N elements asynchronously
  • Non-blocking I/O – Threads released while waiting for responses
  • Backpressure – Consumers control the data flow rate
  • Event Loop – Small thread pool handles many connections

Spring MVC vs Spring WebFlux

Feature Spring MVC Spring WebFlux
I/O Model Blocking Non-blocking
Concurrency Thread per request Event loop
Server Tomcat, Jetty Netty, Tomcat, Jetty
Scalability Limited by threads Highly scalable
Best For CPU-bound tasks I/O-bound tasks
Programming Model Imperative Reactive/Functional

Project Setup

<!-- pom.xml -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.3</version>
</parent>

<dependencies>
    <!-- WebFlux -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webflux</artifactId>
    </dependency>
    
    <!-- Reactive Database -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-r2dbc</artifactId>
    </dependency>
    <dependency>
        <groupId>io.r2dbc</groupId>
        <artifactId>r2dbc-postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Validation -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    
    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.projectreactor</groupId>
        <artifactId>reactor-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
# application.yml
spring:
  r2dbc:
    url: r2dbc:postgresql://localhost:5432/productdb
    username: postgres
    password: postgres
  
logging:
  level:
    org.springframework.r2dbc: DEBUG

Domain Model

// Product.java
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Table("products")
public class Product {

    @Id
    private Long id;

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100)
    private String name;

    private String description;

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be positive")
    private BigDecimal price;

    @NotBlank(message = "Category is required")
    private String category;

    @Min(0)
    private Integer stockQuantity;

    private boolean active = true;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // Constructors
    public Product() {
        this.createdAt = LocalDateTime.now();
    }

    public Product(String name, String description, BigDecimal price, String category) {
        this();
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    // Getters and setters...
}

Reactive Repository

// ProductRepository.java
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface ProductRepository extends ReactiveCrudRepository<Product, Long> {

    Flux<Product> findByCategory(String category);

    Flux<Product> findByActiveTrue();

    Flux<Product> findByPriceBetween(BigDecimal min, BigDecimal max);

    @Query("SELECT * FROM products WHERE LOWER(name) LIKE LOWER(CONCAT('%', :keyword, '%'))")
    Flux<Product> searchByName(String keyword);

    @Query("SELECT * FROM products WHERE category = :category AND active = true ORDER BY price ASC LIMIT :limit")
    Flux<Product> findTopCheapestByCategory(String category, int limit);

    Mono<Long> countByCategory(String category);
}

Service Layer

// ProductService.java
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Service
public class ProductService {

    private final ProductRepository repository;

    public ProductService(ProductRepository repository) {
        this.repository = repository;
    }

    public Flux<Product> findAll() {
        return repository.findByActiveTrue();
    }

    public Mono<Product> findById(Long id) {
        return repository.findById(id)
            .filter(Product::isActive)
            .switchIfEmpty(Mono.error(
                new ProductNotFoundException("Product not found: " + id)
            ));
    }

    public Mono<Product> create(Product product) {
        product.setCreatedAt(LocalDateTime.now());
        return repository.save(product);
    }

    public Mono<Product> update(Long id, Product product) {
        return repository.findById(id)
            .switchIfEmpty(Mono.error(
                new ProductNotFoundException("Product not found: " + id)
            ))
            .flatMap(existing -> {
                existing.setName(product.getName());
                existing.setDescription(product.getDescription());
                existing.setPrice(product.getPrice());
                existing.setCategory(product.getCategory());
                existing.setStockQuantity(product.getStockQuantity());
                existing.setUpdatedAt(LocalDateTime.now());
                return repository.save(existing);
            });
    }

    public Mono<Void> delete(Long id) {
        return repository.findById(id)
            .switchIfEmpty(Mono.error(
                new ProductNotFoundException("Product not found: " + id)
            ))
            .flatMap(product -> {
                product.setActive(false); // Soft delete
                return repository.save(product);
            })
            .then();
    }

    public Flux<Product> findByCategory(String category) {
        return repository.findByCategory(category)
            .filter(Product::isActive);
    }

    public Flux<Product> findByPriceRange(BigDecimal min, BigDecimal max) {
        return repository.findByPriceBetween(min, max)
            .filter(Product::isActive);
    }

    public Flux<Product> search(String keyword) {
        return repository.searchByName(keyword)
            .filter(Product::isActive);
    }
}

Annotation-Based Controller

// ProductController.java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import jakarta.validation.Valid;
import java.math.BigDecimal;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public Flux<Product> getAllProducts() {
        return productService.findAll();
    }

    @GetMapping("/{id}")
    public Mono<Product> getProduct(@PathVariable Long id) {
        return productService.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<Product> createProduct(@Valid @RequestBody Product product) {
        return productService.create(product);
    }

    @PutMapping("/{id}")
    public Mono<Product> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody Product product) {
        return productService.update(id, product);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public Mono<Void> deleteProduct(@PathVariable Long id) {
        return productService.delete(id);
    }

    @GetMapping("/category/{category}")
    public Flux<Product> getByCategory(@PathVariable String category) {
        return productService.findByCategory(category);
    }

    @GetMapping("/search")
    public Flux<Product> search(@RequestParam String keyword) {
        return productService.search(keyword);
    }

    @GetMapping("/price-range")
    public Flux<Product> getByPriceRange(
            @RequestParam BigDecimal min,
            @RequestParam BigDecimal max) {
        return productService.findByPriceRange(min, max);
    }
}

Functional Routing Style

// ProductRouter.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.server.*;
import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;

@Configuration
public class ProductRouter {

    @Bean
    public RouterFunction<ServerResponse> productRoutes(ProductHandler handler) {
        return route()
            .path("/api/v2/products", builder -> builder
                .GET("", handler::getAll)
                .GET("/{id}", handler::getById)
                .POST("", handler::create)
                .PUT("/{id}", handler::update)
                .DELETE("/{id}", handler::delete)
            )
            .build();
    }
}
// ProductHandler.java
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.*;
import reactor.core.publisher.Mono;
import static org.springframework.web.reactive.function.server.ServerResponse.*;

@Component
public class ProductHandler {

    private final ProductService service;

    public ProductHandler(ProductService service) {
        this.service = service;
    }

    public Mono<ServerResponse> getAll(ServerRequest request) {
        return ok().body(service.findAll(), Product.class);
    }

    public Mono<ServerResponse> getById(ServerRequest request) {
        Long id = Long.parseLong(request.pathVariable("id"));
        return service.findById(id)
            .flatMap(product -> ok().bodyValue(product))
            .switchIfEmpty(notFound().build());
    }

    public Mono<ServerResponse> create(ServerRequest request) {
        return request.bodyToMono(Product.class)
            .flatMap(service::create)
            .flatMap(product -> created(URI.create("/api/v2/products/" + product.getId()))
                .bodyValue(product));
    }

    public Mono<ServerResponse> update(ServerRequest request) {
        Long id = Long.parseLong(request.pathVariable("id"));
        return request.bodyToMono(Product.class)
            .flatMap(product -> service.update(id, product))
            .flatMap(updated -> ok().bodyValue(updated))
            .switchIfEmpty(notFound().build());
    }

    public Mono<ServerResponse> delete(ServerRequest request) {
        Long id = Long.parseLong(request.pathVariable("id"));
        return service.delete(id)
            .then(noContent().build());
    }
}

WebClient for External APIs

// ExternalApiService.java
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import java.time.Duration;

@Service
public class ExternalApiService {

    private final WebClient webClient;

    public ExternalApiService(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder
            .baseUrl("https://api.example.com")
            .defaultHeader("Accept", "application/json")
            .build();
    }

    public Mono<ExternalData> fetchData(String id) {
        return webClient.get()
            .uri("/data/{id}", id)
            .retrieve()
            .onStatus(
                status -> status.is4xxClientError(),
                response -> Mono.error(new ClientException("Client error"))
            )
            .onStatus(
                status -> status.is5xxServerError(),
                response -> Mono.error(new ServerException("Server error"))
            )
            .bodyToMono(ExternalData.class)
            .timeout(Duration.ofSeconds(5))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .filter(throwable -> throwable instanceof ServerException))
            .onErrorResume(e -> {
                log.error("Failed to fetch data: {}", e.getMessage());
                return Mono.empty();
            });
    }

    public Mono<Product> enrichProduct(Product product) {
        return fetchData(product.getId().toString())
            .map(external -> {
                product.setExternalRating(external.getRating());
                return product;
            })
            .defaultIfEmpty(product);
    }
}

Global Error Handling

// GlobalExceptionHandler.java
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.support.WebExchangeBindException;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Mono<ErrorResponse> handleNotFound(ProductNotFoundException ex) {
        return Mono.just(new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        ));
    }

    @ExceptionHandler(WebExchangeBindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Mono<ValidationErrorResponse> handleValidation(WebExchangeBindException ex) {
        Map<String, String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                error -> error.getField(),
                error -> error.getDefaultMessage(),
                (e1, e2) -> e1
            ));

        return Mono.just(new ValidationErrorResponse(
            HttpStatus.BAD_REQUEST.value(),
            "Validation failed",
            errors,
            LocalDateTime.now()
        ));
    }
}

public record ErrorResponse(int status, String message, LocalDateTime timestamp) {}
public record ValidationErrorResponse(
    int status, String message, Map<String, String> errors, LocalDateTime timestamp
) {}

Common Mistakes to Avoid

1. Blocking Calls in Reactive Pipelines

// Wrong - blocks the event loop
public Mono<Product> getProduct(Long id) {
    Product product = repository.findById(id).block(); // NEVER do this!
    return Mono.just(product);
}

// Correct - stay reactive
public Mono<Product> getProduct(Long id) {
    return repository.findById(id);
}

2. Not Handling Empty Streams

// Wrong - returns null on not found
public Mono<Product> findById(Long id) {
    return repository.findById(id);
}

// Correct - handle empty case
public Mono<Product> findById(Long id) {
    return repository.findById(id)
        .switchIfEmpty(Mono.error(new ProductNotFoundException(id)));
}

3. Forgetting to Subscribe

// Wrong - nothing happens (cold stream)
productService.create(product);

// Correct - WebFlux subscribes automatically in controllers
// For manual subscription:
productService.create(product).subscribe();

4. Using RestTemplate Instead of WebClient

// Wrong - RestTemplate is blocking
RestTemplate restTemplate = new RestTemplate();
String result = restTemplate.getForObject(url, String.class);

// Correct - WebClient is non-blocking
webClient.get().uri(url).retrieve().bodyToMono(String.class);

Final Thoughts

Spring WebFlux provides a powerful foundation for building high-performance, non-blocking APIs. Its reactive programming model excels at handling I/O-bound workloads with minimal resources. Start with annotation-based controllers for familiarity, then explore functional routing as your reactive skills grow. Remember to use R2DBC for database access, WebClient for external calls, and always avoid blocking operations within reactive pipelines. WebFlux isn’t always necessary—use Spring MVC for CPU-bound tasks or simpler applications—but when you need scalability under high concurrency, WebFlux delivers.

To continue learning about reactive patterns, read Integrating Redis Cache into Spring Applications and REST vs GraphQL vs gRPC. For official documentation, visit the Spring WebFlux Reference and the Project Reactor Documentation.