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 itDependencies
<!-- 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 parallel | Traditional JDBC (blocking!) — use R2DBC or wrap |
| Streaming data (SSE, WebSockets, change streams) | Simple CRUD with low concurrency |
| Microservices with heavy inter-service calls | Team 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 threadPractical: 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+B3Transforming & 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
| Operator | Description | Example |
|---|---|---|
| map | Sync 1:1 transform | flux.map(s -> s.toUpperCase()) |
| flatMap | Async 1:N, interleaved | flux.flatMap(id -> fetchById(id)) |
| flatMapSequential | Async 1:N, ordered | flux.flatMapSequential(id -> fetchById(id)) |
| concatMap | Async 1:N, sequential | flux.concatMap(id -> fetchById(id)) |
| switchMap | Cancel previous, use latest | flux.switchMap(q -> search(q)) |
| cast | Cast to type | flux.cast(String.class) |
| ofType | Filter + cast | flux.ofType(ErrorEvent.class) |
Filter Operators
| Operator | Description | Example |
|---|---|---|
| filter | Keep matching | flux.filter(u -> u.isActive()) |
| distinct | Remove duplicates | flux.distinct() |
| distinctUntilChanged | Remove consecutive dupes | flux.distinctUntilChanged() |
| take | First N elements | flux.take(5) |
| takeLast | Last N elements | flux.takeLast(3) |
| skip | Skip first N | flux.skip(10) |
| elementAt | Element at index | flux.elementAt(2) |
Combine Operators
| Operator | Description | Example |
|---|---|---|
| zip | Pair 1:1 from each | Flux.zip(a, b, (x, y) -> x + y) |
| merge | Interleave as they arrive | Flux.merge(a, b) |
| concat | Sequential (a then b) | Flux.concat(a, b) |
| combineLatest | Latest from each on any emit | Flux.combineLatest(a, b, (x,y) -> x+y) |
| startWith | Prepend elements | flux.startWith("first") |
| switchIfEmpty | Fallback when empty | flux.switchIfEmpty(fallback) |
Error Operators
| Operator | Description | Example |
|---|---|---|
| onErrorReturn | Static fallback value | mono.onErrorReturn("default") |
| onErrorResume | Fallback publisher | mono.onErrorResume(e -> fallback()) |
| onErrorMap | Transform exception | mono.onErrorMap(e -> new AppException(e)) |
| retry | Retry N times | mono.retry(3) |
| retryWhen | Retry with strategy | mono.retryWhen(Retry.backoff(3, Duration.ofSeconds(1))) |
Time & Aggregate Operators
| Operator | Description | Example |
|---|---|---|
| timeout | Error if no item in duration | mono.timeout(Duration.ofSeconds(5)) |
| delayElements | Delay each element | flux.delayElements(Duration.ofMillis(100)) |
| timestamp | Attach timestamp | flux.timestamp() |
| elapsed | Time since previous | flux.elapsed() |
| reduce | Final accumulated value | flux.reduce(0, Integer::sum) |
| scan | Running accumulation | flux.scan(0, Integer::sum) |
| collectList | Collect to List | flux.collectList() |
| collectMap | Collect to Map | flux.collectMap(User::getId, User::getName) |
| count | Count elements | flux.count() |
| hasElements | Check if non-empty | flux.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
| Scheduler | Thread Pool | Use 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 thread | Sequential, ordered work |
| Schedulers.immediate() | Current thread | Testing, 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 library15. Quick Reference Card
Mono Creation Methods
| Method | When 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
| Method | When 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)
| Dependency | Version (Spring Boot 3.3+) |
|---|---|
| Spring Boot | 3.3.x / 3.4.x |
| Project Reactor (reactor-core) | 3.6.x (Reactor 2024.0) |
| Reactor Netty | 1.1.x |
| Spring Data MongoDB Reactive | 4.3.x |
| MongoDB Driver (reactive) | 5.1.x |
| Resilience4j | 2.2.x |
| BlockHound | 1.0.9.RELEASE |
| Testcontainers | 1.20.x |