← Back to Presentations

Project Reactor + WebClient + Reactive MongoDB

Complete Cheatsheet — Spring Boot 3.x / Reactor 2024.x / Spring Data MongoDB Reactive

1. Reactive Programming Fundamentals

Why Reactive? Blocking vs Non-Blocking

❌ Traditional (Thread-per-Request)

  • 1 thread per HTTP request (Tomcat default: 200 threads)
  • Thread blocks on DB call, HTTP call, file I/O
  • 200 concurrent requests = 200 threads waiting
  • Memory: ~1MB stack per thread
  • Scaling: add more threads → diminishing returns

✅ Reactive (Event Loop)

  • Small fixed thread pool (Netty: CPU cores × 2)
  • Thread never blocks — registers callback, moves on
  • 10,000 concurrent requests = same few threads
  • Memory: minimal per request (just the pipeline)
  • Scaling: handles massive I/O concurrency

Reactive Streams Specification

// The 4 interfaces (java.util.concurrent.Flow in Java 9+)
public interface Publisher<T> {
    void subscribe(Subscriber<? super T> subscriber);
}

public interface Subscriber<T> {
    void onSubscribe(Subscription subscription);
    void onNext(T item);
    void onError(Throwable throwable);
    void onComplete();
}

public interface Subscription {
    void request(long n);  // Backpressure: subscriber controls flow
    void cancel();
}

public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
    // Both subscriber and publisher — rarely used directly
}

Mono vs Flux

Mono<T> — 0..1 element

Like Optional but async. Single result or empty.

Use for: single DB lookup, single API call, save operation

Flux<T> — 0..N elements

Like Stream but async + backpressure.

Use for: list queries, SSE streams, real-time feeds

Cold vs Hot Publishers

// COLD: data generated fresh for each subscriber
Mono<String> cold = Mono.fromCallable(() -> fetchFromDB());
cold.subscribe(s -> log.info("Sub1: {}", s)); // fetches
cold.subscribe(s -> log.info("Sub2: {}", s)); // fetches AGAIN

// HOT: data shared among subscribers (like a radio broadcast)
Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer();
Flux<String> hot = sink.asFlux();
hot.subscribe(s -> log.info("Sub1: {}", s));
hot.subscribe(s -> log.info("Sub2: {}", s));
sink.tryEmitNext("shared-event"); // both receive it

Dependencies

<!-- pom.xml — Spring Boot 3.x -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <!-- Includes: Reactor Core, Reactor Netty, WebClient -->
    <!-- Replaces spring-boot-starter-web (Tomcat) with Netty -->
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>

<!-- For testing -->
<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-test</artifactId>
    <scope>test</scope>
</dependency>

<!-- BlockHound — detect blocking calls in reactive pipelines -->
<dependency>
    <groupId>io.projectreactor.tools</groupId>
    <artifactId>blockhound</artifactId>
    <version>1.0.9.RELEASE</version>
    <scope>test</scope>
</dependency>

When to Use Reactive vs When NOT To

✅ Use Reactive❌ Don't Use Reactive
High-concurrency I/O (API gateway, proxy)CPU-bound processing (video encoding, ML)
Many external API calls in parallelTraditional JDBC (blocking!) — use R2DBC or wrap
Streaming data (SSE, WebSockets, change streams)Simple CRUD with low concurrency
Microservices with heavy inter-service callsTeam unfamiliar with reactive — learning curve is real
MongoDB, Redis, Cassandra (native reactive drivers)Blocking libraries without reactive alternatives

2. Mono — The Single Value

Creating Monos

import reactor.core.publisher.Mono;
import java.util.concurrent.CompletableFuture;

// Static factories
Mono<String> just     = Mono.just("hello");           // Immediate value
Mono<String> empty    = Mono.empty();                  // Empty (no value)
Mono<String> error    = Mono.error(new RuntimeException("boom"));

// Lazy creation — value computed on subscribe
Mono<String> callable = Mono.fromCallable(() -> expensiveDbCall());
Mono<String> supplier = Mono.fromSupplier(() -> computeValue());
Mono<String> future   = Mono.fromFuture(() -> CompletableFuture.supplyAsync(() -> "async"));

// defer() — NEW Mono instance per subscriber (critical for retries!)
Mono<String> deferred = Mono.defer(() -> Mono.just(Instant.now().toString()));
// Each subscribe gets a fresh timestamp

// create() — bridging callback-based APIs
Mono<String> bridged = Mono.create(sink -> {
    callbackApi.call(result -> sink.success(result),
                     error  -> sink.error(error));
});

Essential Mono Operators

import reactor.core.publisher.Mono;

Mono<User> userMono = userRepository.findById("123");

// map — synchronous transform (T -> R)
Mono<String> name = userMono.map(user -> user.getName());

// flatMap — async transform (T -> Mono<R>)
Mono<Profile> profile = userMono.flatMap(user ->
    profileService.getProfile(user.getProfileId()));

// filter — keep or discard
Mono<User> active = userMono.filter(user -> user.isActive());

// switchIfEmpty — fallback when empty
Mono<User> withFallback = userMono
    .switchIfEmpty(Mono.defer(() -> createDefaultUser()));

// defaultIfEmpty — static fallback value
Mono<User> withDefault = userMono.defaultIfEmpty(User.anonymous());

// zipWith — combine two Monos
Mono<UserDto> dto = userMono.zipWith(profileMono,
    (user, profile) -> new UserDto(user, profile));

// zipWhen — combine with dependent Mono
Mono<UserDto> dto2 = userMono.zipWhen(
    user -> profileService.getProfile(user.getProfileId()),
    (user, profile) -> new UserDto(user, profile));

// cache — memoize the result (replay to future subscribers)
Mono<Config> cached = configService.loadConfig().cache();
// First subscribe fetches, subsequent replays

// block() — ONLY in tests or main()! Never in reactive chain!
User user = userMono.block(); // Blocks current thread

Practical: Fetch User → Profile → Combine

package com.veloz.reactor.service;

import com.veloz.reactor.dto.UserProfileDto;
import com.veloz.reactor.model.Profile;
import com.veloz.reactor.model.User;
import com.veloz.reactor.repository.ProfileRepository;
import com.veloz.reactor.repository.UserRepository;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;

@Service
public class UserProfileService {

    private final UserRepository userRepository;
    private final ProfileRepository profileRepository;

    public UserProfileService(UserRepository userRepository,
                              ProfileRepository profileRepository) {
        this.userRepository = userRepository;
        this.profileRepository = profileRepository;
    }

    public Mono<UserProfileDto> getUserProfile(String userId) {
        return userRepository.findById(userId)                         // Mono<User>
            .switchIfEmpty(Mono.error(new UserNotFoundException(userId)))
            .flatMap(user -> profileRepository                          // Mono<Profile>
                .findByUserId(user.getId())
                .defaultIfEmpty(Profile.empty())
                .map(profile -> new UserProfileDto(                     // combine
                    user.getId(),
                    user.getName(),
                    user.getEmail(),
                    profile.getAvatarUrl(),
                    profile.getBio()
                ))
            );
    }
}

3. Flux — The Stream

Creating Flux

import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.List;

// Static factories
Flux<String> just      = Flux.just("a", "b", "c");
Flux<String> fromList  = Flux.fromIterable(List.of("x", "y", "z"));
Flux<Integer> range    = Flux.range(1, 10);           // 1, 2, ..., 10
Flux<Long> interval    = Flux.interval(Duration.ofSeconds(1)); // 0, 1, 2, ... every sec

// create() — bridge non-reactive APIs (push model)
Flux<String> created = Flux.create(sink -> {
    eventSource.register(event -> sink.next(event.getData()));
    eventSource.onClose(() -> sink.complete());
    eventSource.onError(e -> sink.error(e));
});

// generate() — stateful synchronous generation (pull model)
Flux<String> generated = Flux.generate(
    () -> 0,                                          // initial state
    (state, sink) -> {
        sink.next("Item-" + state);
        if (state == 9) sink.complete();
        return state + 1;                             // next state
    }
);

Combining Flux

import reactor.core.publisher.Flux;

Flux<String> a = Flux.just("A1", "A2", "A3");
Flux<String> b = Flux.just("B1", "B2", "B3");

// concat — sequential (a completes, then b)
Flux.concat(a, b);      // A1, A2, A3, B1, B2, B3

// merge — interleaved (as items arrive, real-time)
Flux.merge(a, b);       // A1, B1, A2, B2, ... (non-deterministic)

// zip — pair elements 1:1
Flux.zip(a, b, (x, y) -> x + "+" + y);  // A1+B1, A2+B2, A3+B3

Transforming & Filtering

Flux<Integer> numbers = Flux.range(1, 100);

// flatMap — async, INTERLEAVED order (max concurrency)
numbers.flatMap(n -> fetchAsync(n));            // order NOT guaranteed

// flatMapSequential — async, ORIGINAL order
numbers.flatMapSequential(n -> fetchAsync(n));  // order preserved, still parallel

// concatMap — async, SEQUENTIAL (one at a time)
numbers.concatMap(n -> fetchAsync(n));          // order preserved, one-by-one

// filter, distinct, take, skip
numbers.filter(n -> n % 2 == 0)     // even numbers
       .distinct()                   // unique
       .take(10)                     // first 10
       .skip(2)                      // skip first 2
       .collectList();               // Mono<List<Integer>>

// buffer — collect into batches
numbers.buffer(10);                  // Flux<List<Integer>> — batches of 10

// window — sub-Flux per window
numbers.window(10);                  // Flux<Flux<Integer>>

// groupBy — group by key
numbers.groupBy(n -> n % 3)         // Flux<GroupedFlux<Integer, Integer>>
       .flatMap(group -> group.collectList()
           .map(list -> group.key() + ": " + list));

// reduce & scan
numbers.reduce(0, Integer::sum);                 // Mono<Integer> (final sum)
numbers.scan(0, Integer::sum);                   // Flux<Integer> (running total)

Practical: Sensor Data → Batch → Bulk Insert

package com.veloz.reactor.service;

import com.veloz.reactor.model.SensorReading;
import com.veloz.reactor.repository.SensorReadingRepository;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.List;

@Service
public class SensorIngestionService {

    private final SensorReadingRepository repository;

    public SensorIngestionService(SensorReadingRepository repository) {
        this.repository = repository;
    }

    /**
     * Stream sensor data, batch every 100 items OR every 5 seconds,
     * then bulk insert into MongoDB.
     */
    public Flux<List<SensorReading>> ingestSensorStream(
            Flux<SensorReading> sensorStream) {

        return sensorStream
            .bufferTimeout(100, Duration.ofSeconds(5))  // batch: 100 items or 5s
            .flatMap(batch -> repository
                .saveAll(batch)                          // bulk insert
                .collectList()                           // Mono<List<SensorReading>>
                .doOnNext(saved -> log.info("Bulk inserted {} readings", saved.size()))
            , 4);  // max 4 concurrent bulk inserts
    }
}

4. Operators Deep Dive — The Essential 30

Transform Operators

OperatorDescriptionExample
mapSync 1:1 transformflux.map(s -> s.toUpperCase())
flatMapAsync 1:N, interleavedflux.flatMap(id -> fetchById(id))
flatMapSequentialAsync 1:N, orderedflux.flatMapSequential(id -> fetchById(id))
concatMapAsync 1:N, sequentialflux.concatMap(id -> fetchById(id))
switchMapCancel previous, use latestflux.switchMap(q -> search(q))
castCast to typeflux.cast(String.class)
ofTypeFilter + castflux.ofType(ErrorEvent.class)

Filter Operators

OperatorDescriptionExample
filterKeep matchingflux.filter(u -> u.isActive())
distinctRemove duplicatesflux.distinct()
distinctUntilChangedRemove consecutive dupesflux.distinctUntilChanged()
takeFirst N elementsflux.take(5)
takeLastLast N elementsflux.takeLast(3)
skipSkip first Nflux.skip(10)
elementAtElement at indexflux.elementAt(2)

Combine Operators

OperatorDescriptionExample
zipPair 1:1 from eachFlux.zip(a, b, (x, y) -> x + y)
mergeInterleave as they arriveFlux.merge(a, b)
concatSequential (a then b)Flux.concat(a, b)
combineLatestLatest from each on any emitFlux.combineLatest(a, b, (x,y) -> x+y)
startWithPrepend elementsflux.startWith("first")
switchIfEmptyFallback when emptyflux.switchIfEmpty(fallback)

Error Operators

OperatorDescriptionExample
onErrorReturnStatic fallback valuemono.onErrorReturn("default")
onErrorResumeFallback publishermono.onErrorResume(e -> fallback())
onErrorMapTransform exceptionmono.onErrorMap(e -> new AppException(e))
retryRetry N timesmono.retry(3)
retryWhenRetry with strategymono.retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))

Time & Aggregate Operators

OperatorDescriptionExample
timeoutError if no item in durationmono.timeout(Duration.ofSeconds(5))
delayElementsDelay each elementflux.delayElements(Duration.ofMillis(100))
timestampAttach timestampflux.timestamp()
elapsedTime since previousflux.elapsed()
reduceFinal accumulated valueflux.reduce(0, Integer::sum)
scanRunning accumulationflux.scan(0, Integer::sum)
collectListCollect to Listflux.collectList()
collectMapCollect to Mapflux.collectMap(User::getId, User::getName)
countCount elementsflux.count()
hasElementsCheck if non-emptyflux.hasElements()

Side-Effect Operators

flux
    .doOnSubscribe(sub -> log.info("Subscribed!"))
    .doOnNext(item -> log.debug("Processing: {}", item))
    .doOnError(e -> log.error("Error: {}", e.getMessage()))
    .doOnComplete(() -> log.info("Stream completed"))
    .doFinally(signalType -> log.info("Finally: {}", signalType))
    .log()  // Full Reactive Streams signal logging (verbose!)
    .subscribe();

5. Error Handling — Don't Let It Blow

Error Recovery Operators

import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import java.time.Duration;

Mono<String> apiCall = webClient.get()
    .uri("/api/data")
    .retrieve()
    .bodyToMono(String.class);

// onErrorReturn — static fallback
apiCall.onErrorReturn("default-value");

// onErrorResume — dynamic fallback (different publisher)
apiCall.onErrorResume(WebClientResponseException.class, ex -> {
    if (ex.getStatusCode().is4xxClientError()) {
        return Mono.empty();  // client error → empty
    }
    return cacheService.getCachedData();  // server error → cache
});

// onErrorMap — transform exception type
apiCall.onErrorMap(IOException.class,
    ex -> new ServiceUnavailableException("API down", ex));

// retry — simple
apiCall.retry(3);

// retryWhen — exponential backoff (the production way)
apiCall.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
    .maxBackoff(Duration.ofSeconds(10))
    .filter(ex -> ex instanceof ServiceUnavailableException)
    .doBeforeRetry(signal -> log.warn("Retry #{}: {}",
        signal.totalRetries(), signal.failure().getMessage()))
);

// timeout + fallback
apiCall
    .timeout(Duration.ofSeconds(5))
    .onErrorResume(TimeoutException.class, ex -> Mono.just("timeout-fallback"));

⚠️ onErrorContinue vs onErrorResume — The Critical Difference

onErrorResume — Catches error, replaces the entire remaining stream with fallback. ✅ Safe, predictable.

onErrorContinue — Drops the failing element, continues stream. ⚠️ Dangerous! It only works with operators that support it internally. It can "leak" upstream and swallow errors you didn't intend to catch. Almost always wrong.

Rule: Use onErrorResume with flatMap for per-element error handling:

// ✅ CORRECT: per-element error handling
flux.flatMap(item ->
    processItem(item)
        .onErrorResume(e -> {
            log.warn("Skipping item {}: {}", item, e.getMessage());
            return Mono.empty();  // skip this item
        })
);

// ❌ WRONG: onErrorContinue — unpredictable scoping
flux.map(item -> processItem(item))
    .onErrorContinue((e, item) -> log.warn("Skipped: {}", item));

Global Error Handling in WebFlux

package com.veloz.reactor.config;

import com.veloz.reactor.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import reactor.core.publisher.Mono;

@ControllerAdvice
public class GlobalErrorHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public Mono<ResponseEntity<ErrorResponse>> handleNotFound(
            UserNotFoundException ex) {
        return Mono.just(ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage())));
    }

    @ExceptionHandler(ServiceUnavailableException.class)
    public Mono<ResponseEntity<ErrorResponse>> handleServiceDown(
            ServiceUnavailableException ex) {
        return Mono.just(ResponseEntity
            .status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(new ErrorResponse("SERVICE_UNAVAILABLE", ex.getMessage())));
    }

    @ExceptionHandler(Exception.class)
    public Mono<ResponseEntity<ErrorResponse>> handleGeneric(Exception ex) {
        log.error("Unhandled exception", ex);
        return Mono.just(ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong")));
    }
}

Practical: API Call → Retry → Cache Fallback → Default

package com.veloz.reactor.service;

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

@Service
public class ResilientDataService {

    private final WebClient webClient;
    private final CacheService cacheService;

    public ResilientDataService(WebClient webClient, CacheService cacheService) {
        this.webClient = webClient;
        this.cacheService = cacheService;
    }

    public Mono<PricingData> getPricing(String productId) {
        return webClient.get()
            .uri("/api/pricing/{id}", productId)
            .retrieve()
            .bodyToMono(PricingData.class)
            // Step 1: Timeout after 3 seconds
            .timeout(Duration.ofSeconds(3))
            // Step 2: Retry 3x with exponential backoff (only server errors)
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .maxBackoff(Duration.ofSeconds(5))
                .filter(ex -> !(ex instanceof WebClientResponseException.NotFound))
                .doBeforeRetry(sig -> log.warn("Retry #{} for product {}",
                    sig.totalRetries(), productId)))
            // Step 3: Fallback to cache
            .onErrorResume(ex -> {
                log.warn("API failed for {}, trying cache: {}", productId, ex.getMessage());
                return cacheService.getCachedPricing(productId);
            })
            // Step 4: Final default if cache also empty
            .switchIfEmpty(Mono.fromCallable(() -> PricingData.defaultPricing(productId)));
    }
}

6. Backpressure & Schedulers

Schedulers

SchedulerThread PoolUse Case
Schedulers.boundedElastic()Cached, bounded (10×CPU, 100K queued)Blocking I/O: file, JDBC, legacy APIs
Schedulers.parallel()Fixed (CPU cores)CPU-intensive computation
Schedulers.single()Single threadSequential, ordered work
Schedulers.immediate()Current threadTesting, no thread switch

publishOn vs subscribeOn — The Crucial Difference

import reactor.core.scheduler.Schedulers;

// subscribeOn — affects the ENTIRE chain from subscription point
// (where the source runs)
Mono.fromCallable(() -> blockingDbCall())      // runs on boundedElastic
    .subscribeOn(Schedulers.boundedElastic())
    .map(result -> transform(result))           // ALSO on boundedElastic
    .subscribe();

// publishOn — affects DOWNSTREAM operators only
// (switch thread at this point)
Flux.range(1, 100)
    .map(n -> n * 2)                           // runs on original thread
    .publishOn(Schedulers.parallel())           // ← thread switch here
    .map(n -> expensiveCompute(n))             // runs on parallel
    .publishOn(Schedulers.boundedElastic())     // ← another switch
    .flatMap(n -> saveToDb(n))                 // runs on boundedElastic
    .subscribe();

// Rule of thumb:
// subscribeOn = controls where the SOURCE runs (position doesn't matter)
// publishOn   = controls where DOWNSTREAM runs (position matters!)

Backpressure Strategies

Flux<Integer> fastProducer = Flux.range(1, 1_000_000);

// limitRate — request items in smaller batches
fastProducer.limitRate(100);  // request 100 at a time (75% prefetch)

// onBackpressureBuffer — buffer excess items
fastProducer.onBackpressureBuffer(1000);                 // bounded buffer
fastProducer.onBackpressureBuffer(1000, dropped ->       // with overflow handler
    log.warn("Dropped: {}", dropped));

// onBackpressureDrop — drop when downstream can't keep up
fastProducer.onBackpressureDrop(dropped ->
    log.warn("Dropped: {}", dropped));

// onBackpressureLatest — keep only the latest item
fastProducer.onBackpressureLatest();

Parallel Processing

Flux.range(1, 1000)
    .parallel(4)                              // split into 4 rails
    .runOn(Schedulers.parallel())              // assign thread pool
    .map(n -> expensiveCompute(n))            // runs on 4 parallel threads
    .sequential()                             // merge back to single Flux
    .collectList()
    .subscribe();

Practical: Blocking Read → Parallel Process → Blocking Write

package com.veloz.reactor.service;

import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

import java.nio.file.Files;
import java.nio.file.Path;

@Service
public class FileProcessingService {

    public Mono<Long> processFile(Path inputFile, Path outputFile) {
        return Mono.fromCallable(() -> Files.readAllLines(inputFile))    // blocking read
            .subscribeOn(Schedulers.boundedElastic())                    // on elastic
            .flatMapMany(Flux::fromIterable)                             // lines → Flux
            .publishOn(Schedulers.parallel())                            // switch to parallel
            .map(line -> transformLine(line))                            // CPU work
            .collectList()
            .publishOn(Schedulers.boundedElastic())                      // back to elastic
            .flatMap(lines -> Mono.fromCallable(() -> {                  // blocking write
                Files.write(outputFile, lines);
                return (long) lines.size();
            }));
    }

    private String transformLine(String line) {
        // CPU-intensive transformation
        return line.toUpperCase().replaceAll("\s+", " ").trim();
    }
}

7. WebClient — The Reactive HTTP Client

Creating WebClient

package com.veloz.reactor.config;

import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;

import java.time.Duration;
import java.util.concurrent.TimeUnit;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        // Connection pool
        ConnectionProvider provider = ConnectionProvider.builder("custom")
            .maxConnections(500)
            .maxIdleTime(Duration.ofSeconds(20))
            .maxLifeTime(Duration.ofSeconds(60))
            .pendingAcquireTimeout(Duration.ofSeconds(60))
            .evictInBackground(Duration.ofSeconds(120))
            .build();

        // HTTP client with timeouts
        HttpClient httpClient = HttpClient.create(provider)
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
            .responseTimeout(Duration.ofSeconds(10))
            .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(10, TimeUnit.SECONDS))
                .addHandlerLast(new WriteTimeoutHandler(10, TimeUnit.SECONDS)));

        return builder
            .baseUrl("https://api.example.com")
            .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .filter(logRequest())
            .filter(logResponse())
            .build();
    }

    private ExchangeFilterFunction logRequest() {
        return ExchangeFilterFunction.ofRequestProcessor(request -> {
            log.debug("Request: {} {}", request.method(), request.url());
            return Mono.just(request);
        });
    }

    private ExchangeFilterFunction logResponse() {
        return ExchangeFilterFunction.ofResponseProcessor(response -> {
            log.debug("Response: {}", response.statusCode());
            return Mono.just(response);
        });
    }
}

GET, POST, PUT, DELETE

import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

// GET — single object
Mono<User> user = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class);

// GET — list (Flux)
Flux<User> users = webClient.get()
    .uri(uriBuilder -> uriBuilder
        .path("/users")
        .queryParam("status", "active")
        .queryParam("page", 0)
        .queryParam("size", 20)
        .build())
    .retrieve()
    .bodyToFlux(User.class);

// POST
Mono<User> created = webClient.post()
    .uri("/users")
    .bodyValue(new CreateUserRequest("John", "[email protected]"))
    .retrieve()
    .bodyToMono(User.class);

// PUT
Mono<User> updated = webClient.put()
    .uri("/users/{id}", userId)
    .bodyValue(updateRequest)
    .retrieve()
    .bodyToMono(User.class);

// DELETE
Mono<Void> deleted = webClient.delete()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(Void.class);

Error Handling with onStatus

Mono<User> user = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .onStatus(status -> status.value() == 404,
        response -> Mono.error(new UserNotFoundException(userId)))
    .onStatus(status -> status.is4xxClientError(),
        response -> response.bodyToMono(String.class)
            .flatMap(body -> Mono.error(new ClientException(body))))
    .onStatus(status -> status.is5xxServerError(),
        response -> Mono.error(new ServiceException("Server error")))
    .bodyToMono(User.class);

retrieve() vs exchangeToMono()

// retrieve() — simple, auto-handles errors (recommended)
Mono<User> user = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class);

// exchangeToMono() — full control over response (status, headers, body)
Mono<User> user2 = webClient.get()
    .uri("/users/{id}", userId)
    .exchangeToMono(response -> {
        if (response.statusCode().is2xxSuccessful()) {
            return response.bodyToMono(User.class);
        } else if (response.statusCode().value() == 404) {
            return Mono.empty();
        } else {
            return response.createError();
        }
    });

Streaming Responses

// Stream large responses as Flux
Flux<DataChunk> stream = webClient.get()
    .uri("/api/stream")
    .accept(MediaType.APPLICATION_NDJSON)
    .retrieve()
    .bodyToFlux(DataChunk.class);

// File download
Mono<Void> download = webClient.get()
    .uri("/files/{name}", filename)
    .retrieve()
    .bodyToMono(DataBuffer.class)
    .flatMap(dataBuffer -> DataBufferUtils
        .write(Mono.just(dataBuffer), Path.of("/tmp/" + filename))
        .then());

// File upload
Mono<String> upload = webClient.post()
    .uri("/files/upload")
    .contentType(MediaType.MULTIPART_FORM_DATA)
    .body(BodyInserters.fromMultipartData("file",
        new FileSystemResource("/tmp/upload.pdf")))
    .retrieve()
    .bodyToMono(String.class);

Practical: Complete REST Client with Retry

package com.veloz.reactor.client;

import com.veloz.reactor.dto.User;
import com.veloz.reactor.dto.CreateUserRequest;
import com.veloz.reactor.exception.UserNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

import java.time.Duration;

@Component
public class UserServiceClient {

    private final WebClient webClient;

    public UserServiceClient(WebClient.Builder builder) {
        this.webClient = builder
            .baseUrl("https://user-service.internal")
            .build();
    }

    public Mono<User> getUser(String id) {
        return webClient.get()
            .uri("/api/v1/users/{id}", id)
            .retrieve()
            .onStatus(s -> s.value() == 404,
                r -> Mono.error(new UserNotFoundException(id)))
            .bodyToMono(User.class)
            .timeout(Duration.ofSeconds(5))
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .filter(e -> !(e instanceof UserNotFoundException)));
    }

    public Flux<User> getAllUsers() {
        return webClient.get()
            .uri("/api/v1/users")
            .retrieve()
            .bodyToFlux(User.class)
            .timeout(Duration.ofSeconds(10));
    }

    public Mono<User> createUser(CreateUserRequest request) {
        return webClient.post()
            .uri("/api/v1/users")
            .bodyValue(request)
            .retrieve()
            .bodyToMono(User.class);
    }

    public Mono<Void> deleteUser(String id) {
        return webClient.delete()
            .uri("/api/v1/users/{id}", id)
            .retrieve()
            .bodyToMono(Void.class);
    }
}

Practical: Parallel API Calls — Fetch & Combine

package com.veloz.reactor.service;

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Service
public class DashboardService {

    private final WebClient userClient;
    private final WebClient orderClient;
    private final WebClient inventoryClient;

    public DashboardService(WebClient.Builder builder) {
        this.userClient = builder.baseUrl("https://user-service").build();
        this.orderClient = builder.baseUrl("https://order-service").build();
        this.inventoryClient = builder.baseUrl("https://inventory-service").build();
    }

    public Mono<DashboardDto> getDashboard(String userId) {
        Mono<UserProfile> profile = userClient.get()
            .uri("/users/{id}/profile", userId)
            .retrieve().bodyToMono(UserProfile.class);

        Mono<List<Order>> orders = orderClient.get()
            .uri("/orders?userId={id}", userId)
            .retrieve().bodyToFlux(Order.class)
            .collectList();

        Mono<InventorySummary> inventory = inventoryClient.get()
            .uri("/inventory/summary")
            .retrieve().bodyToMono(InventorySummary.class);

        // All 3 calls execute IN PARALLEL, combine when all complete
        return Mono.zip(profile, orders, inventory)
            .map(tuple -> new DashboardDto(
                tuple.getT1(),  // UserProfile
                tuple.getT2(),  // List<Order>
                tuple.getT3()   // InventorySummary
            ));
    }
}

8. WebClient Advanced Patterns

Bearer Token Authentication

@Bean
public WebClient securedWebClient(WebClient.Builder builder) {
    return builder
        .baseUrl("https://api.example.com")
        .filter((request, next) -> tokenService.getToken()
            .map(token -> ClientRequest.from(request)
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
                .build())
            .flatMap(next::exchange))
        .build();
}

Circuit Breaker with Resilience4j

package com.veloz.reactor.config;

import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import io.github.resilience4j.reactor.circuitbreaker.operator.CircuitBreakerOperator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
public class ResilienceConfig {

    @Bean
    public CircuitBreaker apiCircuitBreaker() {
        CircuitBreakerConfig config = CircuitBreakerConfig.custom()
            .failureRateThreshold(50)                    // open at 50% failure
            .waitDurationInOpenState(Duration.ofSeconds(30))
            .slidingWindowSize(10)
            .minimumNumberOfCalls(5)
            .build();
        return CircuitBreaker.of("api-cb", config);
    }
}

// Usage in service:
@Service
public class ProtectedApiClient {

    private final WebClient webClient;
    private final CircuitBreaker circuitBreaker;

    public Mono<Data> getData(String id) {
        return webClient.get()
            .uri("/data/{id}", id)
            .retrieve()
            .bodyToMono(Data.class)
            .transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
            .onErrorResume(CallNotPermittedException.class,
                e -> cacheService.getCached(id));  // circuit open → cache
    }
}

Pagination with expand()

package com.veloz.reactor.client;

import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@Component
public class PaginatedApiClient {

    private final WebClient webClient;

    public Flux<Item> fetchAllItems() {
        return fetchPage(0)
            .expand(page -> {                          // recursively fetch next pages
                if (page.hasNext()) {
                    return fetchPage(page.getNumber() + 1);
                }
                return Mono.empty();                    // stop when no more pages
            })
            .flatMapIterable(Page::getContent);         // Page<Item> → Item stream
    }

    private Mono<Page<Item>> fetchPage(int pageNum) {
        return webClient.get()
            .uri(uri -> uri.path("/api/items")
                .queryParam("page", pageNum)
                .queryParam("size", 100)
                .build())
            .retrieve()
            .bodyToMono(new ParameterizedTypeReference<Page<Item>>() {});
    }
}

SSE Client

import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;

// Consume Server-Sent Events
Flux<ServerSentEvent<NotificationEvent>> sseStream = webClient.get()
    .uri("/api/notifications/stream")
    .retrieve()
    .bodyToFlux(new ParameterizedTypeReference<ServerSentEvent<NotificationEvent>>() {});

sseStream
    .filter(sse -> sse.data() != null)
    .map(ServerSentEvent::data)
    .doOnNext(event -> log.info("Received: {}", event))
    .subscribe();

WebSocket Client

import org.springframework.web.reactive.socket.client.ReactorNettyWebSocketClient;
import org.springframework.web.reactive.socket.WebSocketMessage;
import reactor.core.publisher.Mono;

ReactorNettyWebSocketClient wsClient = new ReactorNettyWebSocketClient();

wsClient.execute(URI.create("ws://localhost:8080/ws/events"),
    session -> {
        Flux<WebSocketMessage> output = Flux.interval(Duration.ofSeconds(5))
            .map(i -> session.textMessage("ping-" + i));

        return session.send(output)
            .thenMany(session.receive()
                .map(WebSocketMessage::getPayloadAsText)
                .doOnNext(msg -> log.info("Received: {}", msg)))
            .then();
    }
).subscribe();

9. Reactive MongoDB — Spring Data

Document Model

package com.veloz.reactor.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;

import java.time.Instant;
import java.util.List;

@Document(collection = "users")
@CompoundIndex(name = "email_status_idx", def = "{'email': 1, 'status': 1}")
public class User {

    @Id
    private String id;

    @Indexed(unique = true)
    private String email;

    private String name;
    private String status;
    private List<String> roles;

    @CreatedDate
    private Instant createdAt;

    @LastModifiedDate
    private Instant updatedAt;

    @Version
    private Long version;  // optimistic locking

    // constructors, getters, setters...
}

@Document(collection = "orders")
@CompoundIndex(name = "user_status_idx", def = "{'userId': 1, 'status': 1}")
public class Order {

    @Id
    private String id;
    private String userId;
    private String status;
    private Double totalAmount;
    private List<OrderItem> items;

    @Indexed(expireAfterSeconds = 2592000)  // TTL: 30 days
    private Instant createdAt;
}

Reactive Repository

package com.veloz.reactor.repository;

import com.veloz.reactor.model.User;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.mongodb.repository.ReactiveMongoRepository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

public interface UserRepository extends ReactiveMongoRepository<User, String> {

    // Derived query methods (same as non-reactive, but return Mono/Flux)
    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(String status);
    Flux<User> findByRolesContaining(String role);

    // Custom JSON query
    @Query("{ 'status': ?0, 'roles': { $in: [?1] } }")
    Flux<User> findActiveWithRole(String status, String role);

    // Exists check
    Mono<Boolean> existsByEmail(String email);

    // Count
    Mono<Long> countByStatus(String status);

    // Delete
    Mono<Void> deleteByEmail(String email);
}

ReactiveMongoTemplate — Complex Operations

package com.veloz.reactor.repository;

import com.veloz.reactor.model.Order;
import com.veloz.reactor.dto.OrderSummary;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;

@Repository
public class OrderCustomRepository {

    private final ReactiveMongoTemplate template;

    public OrderCustomRepository(ReactiveMongoTemplate template) {
        this.template = template;
    }

    // Complex query with criteria
    public Flux<Order> findPendingOrders(String userId, double minAmount) {
        Query query = Query.query(
            Criteria.where("userId").is(userId)
                .and("status").is("PENDING")
                .and("totalAmount").gte(minAmount)
        );
        return template.find(query, Order.class);
    }

    // Update
    public Mono<Long> markAsShipped(String orderId) {
        Query query = Query.query(Criteria.where("id").is(orderId));
        Update update = new Update()
            .set("status", "SHIPPED")
            .currentDate("updatedAt");
        return template.updateFirst(query, update, Order.class)
            .map(result -> result.getModifiedCount());
    }

    // Aggregation pipeline
    public Flux<OrderSummary> getOrderSummaryByUser() {
        Aggregation agg = newAggregation(
            match(Criteria.where("status").ne("CANCELLED")),
            group("userId")
                .count().as("orderCount")
                .sum("totalAmount").as("totalSpent")
                .avg("totalAmount").as("averageOrder"),
            sort(Sort.Direction.DESC, "totalSpent"),
            project()
                .and("_id").as("userId")
                .andInclude("orderCount", "totalSpent", "averageOrder")
        );
        return template.aggregate(agg, "orders", OrderSummary.class);
    }

    // Bulk operations
    public Mono<Long> bulkUpdateStatus(List<String> orderIds, String newStatus) {
        Query query = Query.query(Criteria.where("id").in(orderIds));
        Update update = new Update().set("status", newStatus);
        return template.updateMulti(query, update, Order.class)
            .map(result -> result.getModifiedCount());
    }
}

Change Streams — Real-Time Notifications

package com.veloz.reactor.service;

import com.veloz.reactor.model.Order;
import org.springframework.data.mongodb.core.ChangeStreamEvent;
import org.springframework.data.mongodb.core.ReactiveMongoTemplate;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import static org.springframework.data.mongodb.core.query.Criteria.where;

@Service
public class OrderChangeStreamService {

    private final ReactiveMongoTemplate template;

    public OrderChangeStreamService(ReactiveMongoTemplate template) {
        this.template = template;
    }

    public Flux<Order> watchNewOrders() {
        return template.changeStream(Order.class)
            .watchCollection("orders")
            .filter(newAggregation(match(
                where("operationType").is("insert"))))
            .listen()
            .map(ChangeStreamEvent::getBody)
            .doOnNext(order -> log.info("New order: {}", order.getId()));
    }
}

10. Reactive MongoDB Advanced

Tailable Cursors (Capped Collections)

// Create capped collection (run once at startup or via migration)
@Configuration
public class MongoConfig {
    @Bean
    CommandLineRunner initCappedCollection(ReactiveMongoTemplate template) {
        return args -> template.createCollection("notifications",
            CollectionOptions.empty()
                .capped()
                .size(1048576)        // 1MB
                .maxDocuments(1000))
            .subscribe();
    }
}

// Tailable query — streams forever as new documents arrive
public interface NotificationRepository
    extends ReactiveMongoRepository<Notification, String> {

    @Tailable
    Flux<Notification> findByUserId(String userId);
}

Transactions

package com.veloz.reactor.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.ReactiveMongoDatabaseFactory;
import org.springframework.data.mongodb.ReactiveMongoTransactionManager;
import org.springframework.transaction.ReactiveTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
public class MongoTransactionConfig {

    @Bean
    public ReactiveTransactionManager transactionManager(
            ReactiveMongoDatabaseFactory factory) {
        return new ReactiveMongoTransactionManager(factory);
    }
}

// Usage — @Transactional on reactive methods
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryRepository inventoryRepository;

    @Transactional
    public Mono<Order> placeOrder(CreateOrderRequest request) {
        return inventoryRepository.decrementStock(
                request.getProductId(), request.getQuantity())
            .flatMap(updated -> {
                if (updated.getStock() < 0) {
                    return Mono.error(new InsufficientStockException());
                }
                return orderRepository.save(new Order(request));
            });
        // If any step fails → entire transaction rolls back
        // Requires MongoDB replica set (not standalone)
    }
}

GridFS for Large Files

package com.veloz.reactor.service;

import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.mongodb.gridfs.ReactiveGridFsResource;
import org.springframework.data.mongodb.gridfs.ReactiveGridFsTemplate;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import static org.springframework.data.mongodb.core.query.Criteria.where;
import static org.springframework.data.mongodb.core.query.Query.query;

@Service
public class FileStorageService {

    private final ReactiveGridFsTemplate gridFsTemplate;

    public FileStorageService(ReactiveGridFsTemplate gridFsTemplate) {
        this.gridFsTemplate = gridFsTemplate;
    }

    public Mono<String> uploadFile(FilePart filePart, String uploadedBy) {
        var metadata = new org.bson.Document()
            .append("uploadedBy", uploadedBy)
            .append("contentType", filePart.headers().getContentType().toString());

        return gridFsTemplate.store(
            filePart.content(),       // Flux<DataBuffer>
            filePart.filename(),
            metadata
        ).map(objectId -> objectId.toHexString());
    }

    public Flux<DataBuffer> downloadFile(String filename) {
        return gridFsTemplate.findOne(query(where("filename").is(filename)))
            .flatMap(gridFsTemplate::getResource)
            .flatMapMany(ReactiveGridFsResource::getDownloadStream);
    }
}

Geospatial Queries

@Document(collection = "stores")
public class Store {
    @Id private String id;
    private String name;
    @GeoSpatialIndexed(type = GeoSpatialIndexType.GEO_2DSPHERE)
    private GeoJsonPoint location;
}

// Repository
public interface StoreRepository extends ReactiveMongoRepository<Store, String> {
    Flux<Store> findByLocationNear(Point point, Distance distance);
}

// Usage
Point userLocation = new Point(8.5417, 47.3769);  // Zurich
Distance radius = new Distance(5, Metrics.KILOMETERS);
storeRepository.findByLocationNear(userLocation, radius)
    .subscribe(store -> log.info("Nearby: {}", store.getName()));

Full-Text Search

// Ensure text index exists
@Document(collection = "articles")
@CompoundIndex(name = "text_idx",
    def = "{'title': 'text', 'content': 'text'}",
    weights = "{'title': 10, 'content': 1}")
public class Article {
    @Id private String id;
    private String title;
    private String content;
}

// Query with ReactiveMongoTemplate
public Flux<Article> searchArticles(String searchTerm) {
    TextCriteria criteria = TextCriteria.forDefaultLanguage()
        .matchingPhrase(searchTerm);
    Query query = TextQuery.queryText(criteria)
        .sortByScore()
        .limit(20);
    return template.find(query, Article.class);
}

11. Reactive REST Controllers

Annotation-Based Controller

package com.veloz.reactor.controller;

import com.veloz.reactor.dto.CreateUserRequest;
import com.veloz.reactor.model.User;
import com.veloz.reactor.repository.UserRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping
    public Flux<User> getAllUsers() {
        return userRepository.findAll();
    }

    @GetMapping("/{id}")
    public Mono<ResponseEntity<User>> getUser(@PathVariable String id) {
        return userRepository.findById(id)
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Mono<User> createUser(@Valid @RequestBody Mono<CreateUserRequest> request) {
        return request
            .map(req -> new User(req.getName(), req.getEmail()))
            .flatMap(userRepository::save);
    }

    @PutMapping("/{id}")
    public Mono<ResponseEntity<User>> updateUser(
            @PathVariable String id,
            @Valid @RequestBody CreateUserRequest request) {
        return userRepository.findById(id)
            .flatMap(existing -> {
                existing.setName(request.getName());
                existing.setEmail(request.getEmail());
                return userRepository.save(existing);
            })
            .map(ResponseEntity::ok)
            .defaultIfEmpty(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public Mono<Void> deleteUser(@PathVariable String id) {
        return userRepository.deleteById(id);
    }

    // SSE streaming endpoint
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<User> streamUsers() {
        return userRepository.findAll()
            .delayElements(java.time.Duration.ofMillis(500));
    }
}

Functional Endpoints (RouterFunction)

package com.veloz.reactor.config;

import com.veloz.reactor.handler.UserHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;

import static org.springframework.web.reactive.function.server.RequestPredicates.*;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;

@Configuration
public class RouterConfig {

    @Bean
    public RouterFunction<ServerResponse> userRoutes(UserHandler handler) {
        return route()
            .path("/api/v2/users", builder -> builder
                .GET("", handler::listUsers)
                .GET("/{id}", handler::getUser)
                .POST("", contentType(MediaType.APPLICATION_JSON), handler::createUser)
                .PUT("/{id}", contentType(MediaType.APPLICATION_JSON), handler::updateUser)
                .DELETE("/{id}", handler::deleteUser))
            .build();
    }
}

// Handler
package com.veloz.reactor.handler;

import com.veloz.reactor.model.User;
import com.veloz.reactor.repository.UserRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;

import static org.springframework.web.reactive.function.server.ServerResponse.*;

@Component
public class UserHandler {

    private final UserRepository userRepository;

    public UserHandler(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public Mono<ServerResponse> listUsers(ServerRequest request) {
        return ok().body(userRepository.findAll(), User.class);
    }

    public Mono<ServerResponse> getUser(ServerRequest request) {
        String id = request.pathVariable("id");
        return userRepository.findById(id)
            .flatMap(user -> ok().bodyValue(user))
            .switchIfEmpty(notFound().build());
    }

    public Mono<ServerResponse> createUser(ServerRequest request) {
        return request.bodyToMono(User.class)
            .flatMap(userRepository::save)
            .flatMap(saved -> created(URI.create("/api/v2/users/" + saved.getId()))
                .bodyValue(saved));
    }

    public Mono<ServerResponse> updateUser(ServerRequest request) {
        String id = request.pathVariable("id");
        return request.bodyToMono(User.class)
            .flatMap(body -> userRepository.findById(id)
                .flatMap(existing -> {
                    existing.setName(body.getName());
                    existing.setEmail(body.getEmail());
                    return userRepository.save(existing);
                }))
            .flatMap(updated -> ok().bodyValue(updated))
            .switchIfEmpty(notFound().build());
    }

    public Mono<ServerResponse> deleteUser(ServerRequest request) {
        String id = request.pathVariable("id");
        return userRepository.deleteById(id).then(noContent().build());
    }
}

CORS Configuration

@Configuration
public class CorsConfig {

    @Bean
    public WebFluxConfigurer corsConfigurer() {
        return new WebFluxConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                    .allowedOrigins("https://app.example.com")
                    .allowedMethods("GET", "POST", "PUT", "DELETE")
                    .allowedHeaders("*")
                    .maxAge(3600);
            }
        };
    }
}

12. Testing Reactive Code

StepVerifier — THE Tool

package com.veloz.reactor.service;

import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Duration;

class ReactorTests {

    @Test
    void testMono() {
        Mono<String> mono = Mono.just("hello");

        StepVerifier.create(mono)
            .expectNext("hello")
            .verifyComplete();
    }

    @Test
    void testFlux() {
        Flux<Integer> flux = Flux.just(1, 2, 3);

        StepVerifier.create(flux)
            .expectNext(1)
            .expectNext(2)
            .expectNext(3)
            .verifyComplete();
    }

    @Test
    void testError() {
        Mono<String> errorMono = Mono.error(new RuntimeException("boom"));

        StepVerifier.create(errorMono)
            .expectErrorMatches(e -> e instanceof RuntimeException
                && e.getMessage().equals("boom"))
            .verify();
    }

    @Test
    void testFluxAdvanced() {
        Flux<String> flux = Flux.just("A", "B", "C", "D", "E");

        StepVerifier.create(flux)
            .expectNextCount(3)               // skip checking first 3
            .expectNextMatches(s -> s.equals("D"))
            .thenConsumeWhile(s -> true)      // consume remaining
            .verifyComplete();
    }

    @Test
    void testWithVirtualTime() {
        // Test time-dependent flows WITHOUT actually waiting
        StepVerifier.withVirtualTime(() ->
            Flux.interval(Duration.ofHours(1)).take(3))
            .expectSubscription()
            .thenAwait(Duration.ofHours(3))   // fast-forward 3 hours
            .expectNext(0L, 1L, 2L)
            .verifyComplete();
    }
}

TestPublisher — Manual Control

import reactor.test.publisher.TestPublisher;

@Test
void testWithTestPublisher() {
    TestPublisher<String> publisher = TestPublisher.create();

    Flux<String> flux = publisher.flux()
        .map(String::toUpperCase);

    StepVerifier.create(flux)
        .then(() -> publisher.emit("a", "b", "c"))
        .expectNext("A", "B", "C")
        .verifyComplete();
}

@Test
void testErrorScenario() {
    TestPublisher<String> publisher = TestPublisher.create();

    StepVerifier.create(publisher.flux().map(String::toUpperCase))
        .then(() -> publisher.next("a"))
        .expectNext("A")
        .then(() -> publisher.error(new RuntimeException("test error")))
        .expectError(RuntimeException.class)
        .verify();
}

WebTestClient — Test Controllers

package com.veloz.reactor.controller;

import com.veloz.reactor.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.reactive.server.WebTestClient;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class UserControllerIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void shouldCreateUser() {
        webTestClient.post()
            .uri("/api/v1/users")
            .bodyValue(new CreateUserRequest("John", "[email protected]"))
            .exchange()
            .expectStatus().isCreated()
            .expectBody(User.class)
            .value(user -> {
                assertThat(user.getName()).isEqualTo("John");
                assertThat(user.getEmail()).isEqualTo("[email protected]");
                assertThat(user.getId()).isNotNull();
            });
    }

    @Test
    void shouldReturn404ForMissingUser() {
        webTestClient.get()
            .uri("/api/v1/users/{id}", "nonexistent")
            .exchange()
            .expectStatus().isNotFound();
    }

    @Test
    void shouldStreamUsers() {
        webTestClient.get()
            .uri("/api/v1/users/stream")
            .accept(MediaType.TEXT_EVENT_STREAM)
            .exchange()
            .expectStatus().isOk()
            .returnResult(User.class)
            .getResponseBody()
            .take(3)
            .as(StepVerifier::create)
            .expectNextCount(3)
            .verifyComplete();
    }
}

Test WebClient with MockWebServer

package com.veloz.reactor.client;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.*;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.test.StepVerifier;

class UserServiceClientTest {

    private static MockWebServer mockServer;
    private UserServiceClient client;

    @BeforeAll
    static void setUp() throws Exception {
        mockServer = new MockWebServer();
        mockServer.start();
    }

    @AfterAll
    static void tearDown() throws Exception {
        mockServer.shutdown();
    }

    @BeforeEach
    void init() {
        WebClient webClient = WebClient.builder()
            .baseUrl(mockServer.url("/").toString())
            .build();
        client = new UserServiceClient(webClient);
    }

    @Test
    void shouldFetchUser() {
        mockServer.enqueue(new MockResponse()
            .setBody("{\"id\":\"1\",\"name\":\"John\",\"email\":\"[email protected]\"}")
            .addHeader("Content-Type", "application/json"));

        StepVerifier.create(client.getUser("1"))
            .expectNextMatches(user ->
                user.getName().equals("John") && user.getEmail().equals("[email protected]"))
            .verifyComplete();
    }

    @Test
    void shouldRetryOnServerError() {
        mockServer.enqueue(new MockResponse().setResponseCode(503));
        mockServer.enqueue(new MockResponse().setResponseCode(503));
        mockServer.enqueue(new MockResponse()
            .setBody("{\"id\":\"1\",\"name\":\"John\"}")
            .addHeader("Content-Type", "application/json"));

        StepVerifier.create(client.getUser("1"))
            .expectNextMatches(user -> user.getName().equals("John"))
            .verifyComplete();

        assertThat(mockServer.getRequestCount()).isEqualTo(3);
    }
}

// Add to pom.xml:
// <dependency>
//     <groupId>com.squareup.okhttp3</groupId>
//     <artifactId>mockwebserver</artifactId>
//     <scope>test</scope>
// </dependency>

MongoDB Test with Testcontainers

package com.veloz.reactor.repository;

import com.veloz.reactor.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MongoDBContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import reactor.test.StepVerifier;

@DataMongoTest
@Testcontainers
class UserRepositoryTest {

    @Container
    static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");

    @DynamicPropertySource
    static void mongoProps(DynamicPropertyRegistry registry) {
        registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl);
    }

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldSaveAndFind() {
        User user = new User("John", "[email protected]");

        StepVerifier.create(
            userRepository.save(user)
                .then(userRepository.findByEmail("[email protected]")))
            .expectNextMatches(found ->
                found.getName().equals("John") && found.getId() != null)
            .verifyComplete();
    }
}

13. Production Patterns

Pattern 1: Reactive Cache-Aside

@Service
public class CachedUserService {

    private final ReactiveRedisTemplate<String, User> redis;
    private final UserRepository mongoRepo;

    public Mono<User> getUser(String id) {
        String cacheKey = "user:" + id;

        return redis.opsForValue().get(cacheKey)              // 1. Check cache
            .switchIfEmpty(Mono.defer(() ->                     // 2. Cache miss
                mongoRepo.findById(id)                         // 3. Fetch from DB
                    .flatMap(user -> redis.opsForValue()
                        .set(cacheKey, user, Duration.ofMinutes(30))  // 4. Populate cache
                        .thenReturn(user))
            ));
    }
}

Pattern 2: Fan-Out / Fan-In

@Service
public class SearchAggregator {

    private final List<SearchProvider> providers;  // N search backends

    public Mono<AggregatedResults> search(String query) {
        return Flux.fromIterable(providers)
            .flatMap(provider -> provider.search(query)        // scatter to all
                .timeout(Duration.ofSeconds(3))
                .onErrorResume(e -> {
                    log.warn("Provider {} failed: {}", provider.name(), e.getMessage());
                    return Mono.empty();                       // skip failed
                }))
            .collectList()                                     // fan-in
            .map(results -> AggregatedResults.merge(results)); // aggregate
    }
}

Pattern 3: Rate-Limited API Consumer

@Service
public class RateLimitedApiConsumer {

    // Process items with controlled concurrency (max 10 parallel)
    public Flux<Result> processAll(List<String> ids) {
        return Flux.fromIterable(ids)
            .flatMap(id -> apiClient.fetch(id), 10);  // maxConcurrency = 10
    }

    // Token bucket: 1 request per 100ms (10 req/sec)
    public Flux<Result> processRateLimited(List<String> ids) {
        return Flux.fromIterable(ids)
            .zipWith(Flux.interval(Duration.ofMillis(100)))  // pace: 1 per 100ms
            .map(Tuple2::getT1)
            .flatMap(id -> apiClient.fetch(id));
    }
}

Pattern 4: Event-Driven Pipeline (Change Stream)

@Service
public class OrderEventPipeline {

    private final ReactiveMongoTemplate template;
    private final NotificationService notificationService;
    private final AnalyticsService analyticsService;

    @PostConstruct
    public void startPipeline() {
        template.changeStream(Order.class)
            .watchCollection("orders")
            .listen()
            .filter(event -> "insert".equals(event.getOperationType().getValue()))
            .map(ChangeStreamEvent::getBody)
            .flatMap(order -> Mono.when(                       // parallel side effects
                notificationService.notifyUser(order),
                analyticsService.trackOrder(order)
            ).thenReturn(order))
            .doOnError(e -> log.error("Pipeline error", e))
            .retry()                                            // auto-reconnect
            .subscribe();
    }
}

Pattern 5: Resilient Service Call

@Service
public class ResilientServiceClient {

    private final WebClient webClient;
    private final CircuitBreaker circuitBreaker;
    private final CacheService cache;

    public Mono<Data> getData(String id) {
        return webClient.get()
            .uri("/data/{id}", id)
            .retrieve()
            .bodyToMono(Data.class)
            // 1. Timeout
            .timeout(Duration.ofSeconds(3))
            // 2. Retry with exponential backoff
            .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))
                .maxBackoff(Duration.ofSeconds(5)))
            // 3. Circuit breaker
            .transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
            // 4. Fallback to cache
            .onErrorResume(e -> cache.get("data:" + id))
            // 5. Final default
            .switchIfEmpty(Mono.just(Data.defaultValue()));
    }
}

14. Common Pitfalls & Anti-Patterns

❌ Blocking in Reactive Pipeline

// WRONG — blocks the event loop thread!
Mono.just(userId)
    .map(id -> jdbcTemplate.queryForObject(            // BLOCKING!
        "SELECT * FROM users WHERE id = ?", User.class, id))
    .subscribe();

// FIX — offload to boundedElastic
Mono.fromCallable(() -> jdbcTemplate.queryForObject(
        "SELECT * FROM users WHERE id = ?", User.class, userId))
    .subscribeOn(Schedulers.boundedElastic())
    .subscribe();

// DETECT — use BlockHound in tests
BlockHound.install();  // Add to test setup, throws on any blocking call

❌ Nothing Happens Without Subscribe

// WRONG — this does NOTHING (no subscriber)
userRepository.save(user);                // returns Mono, nobody subscribes
webClient.get().uri("/api").retrieve();   // same — nothing happens

// In WebFlux controllers, Spring subscribes for you:
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable String id) {
    return userRepository.findById(id);  // Spring subscribes to the returned Mono
}

❌ flatMap vs concatMap — Order Matters!

// flatMap — interleaves, NO ordering guarantee
Flux.just(1, 2, 3)
    .flatMap(n -> fetchAsync(n))   // might return: 2, 1, 3

// concatMap — preserves order, sequential
Flux.just(1, 2, 3)
    .concatMap(n -> fetchAsync(n)) // always returns: 1, 2, 3

// flatMapSequential — parallel execution, ordered results
Flux.just(1, 2, 3)
    .flatMapSequential(n -> fetchAsync(n)) // parallel but ordered: 1, 2, 3

❌ Mono.just(expensiveCall()) — Eager Evaluation!

// WRONG — expensiveCall() executes IMMEDIATELY, not lazily
Mono<Data> data = Mono.just(expensiveCall());  // called NOW, even before subscribe

// FIX — use fromCallable or defer
Mono<Data> data = Mono.fromCallable(() -> expensiveCall());  // called on subscribe
Mono<Data> data = Mono.defer(() -> Mono.just(expensiveCall())); // also lazy

❌ block() in Reactive Chain — Deadlock Risk

// WRONG — can deadlock if called on event loop thread
webClient.get().uri("/api").retrieve().bodyToMono(Data.class)
    .flatMap(data -> {
        String result = anotherMono.block();  // DEADLOCK!
        return Mono.just(process(data, result));
    });

// FIX — compose with flatMap
webClient.get().uri("/api").retrieve().bodyToMono(Data.class)
    .flatMap(data -> anotherMono
        .map(result -> process(data, result)));

❌ Sharing Mutable State

// WRONG — not thread-safe!
List<String> results = new ArrayList<>();
flux.doOnNext(item -> results.add(item))  // concurrent modification!
    .subscribe();

// FIX — use operators
flux.collectList().subscribe(results -> process(results));

⚠️ Context Propagation (MDC, Tracing)

// ThreadLocal doesn't work in reactive (threads are shared!)
// WRONG:
MDC.put("requestId", requestId);  // lost after thread switch

// FIX — use Reactor Context
Mono.deferContextual(ctx -> {
    String reqId = ctx.get("requestId");
    log.info("[{}] Processing...", reqId);
    return processRequest();
})
.contextWrite(Context.of("requestId", requestId));

// For automatic MDC propagation, add:
// Hooks.onEachOperator(Operators.lift(new MDCContextLifter<>()));
// Or use Micrometer Context Propagation library

15. Quick Reference Card

Mono Creation Methods

MethodWhen to Use
Mono.just(value)Already have the value (eager)
Mono.empty()No value (like Optional.empty)
Mono.error(ex)Signal an error
Mono.fromCallable(() -> ...)Lazy computation (may throw)
Mono.fromSupplier(() -> ...)Lazy computation (no checked exceptions)
Mono.fromFuture(() -> cf)Bridge CompletableFuture
Mono.defer(() -> mono)New Mono per subscriber (retries!)
Mono.create(sink -> ...)Bridge callback APIs
Mono.delay(duration)Emit 0L after delay

Flux Creation Methods

MethodWhen to Use
Flux.just(a, b, c)Known values
Flux.fromIterable(list)From collection
Flux.range(start, count)Integer range
Flux.interval(duration)Periodic timer (0, 1, 2, ...)
Flux.create(sink -> ...)Bridge async/callback APIs
Flux.generate(state, generator)Stateful sync generation
Flux.concat(a, b)Sequential combination
Flux.merge(a, b)Interleaved combination
Flux.zip(a, b)Pair elements 1:1
Flux.empty()Empty stream

WebClient Quick Setup

@Bean
public WebClient webClient(WebClient.Builder builder) {
    return builder
        .baseUrl("https://api.example.com")
        .defaultHeader(HttpHeaders.CONTENT_TYPE,
            MediaType.APPLICATION_JSON_VALUE)
        .build();
}

// Usage
Mono<User> user = webClient.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(User.class)
    .timeout(Duration.ofSeconds(5))
    .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)));

Reactive MongoDB Repository Template

public interface UserRepository
    extends ReactiveMongoRepository<User, String> {

    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(String status);

    @Query("{ 'roles': { $in: [?0] } }")
    Flux<User> findByRole(String role);
}

StepVerifier Template

// Mono test
StepVerifier.create(mono)
    .expectNext(expectedValue)
    .verifyComplete();

// Flux test
StepVerifier.create(flux)
    .expectNextCount(n)
    .verifyComplete();

// Error test
StepVerifier.create(errorMono)
    .expectError(ExpectedException.class)
    .verify();

// Virtual time test
StepVerifier.withVirtualTime(() -> flux)
    .thenAwait(Duration.ofHours(1))
    .expectNextCount(n)
    .verifyComplete();

Dependency Versions (Spring Boot 3.x)

DependencyVersion (Spring Boot 3.3+)
Spring Boot3.3.x / 3.4.x
Project Reactor (reactor-core)3.6.x (Reactor 2024.0)
Reactor Netty1.1.x
Spring Data MongoDB Reactive4.3.x
MongoDB Driver (reactive)5.1.x
Resilience4j2.2.x
BlockHound1.0.9.RELEASE
Testcontainers1.20.x