As a tech lead reviewing a WebFlux codebase, how do Mono/Flux cardinality and the eager-vs-lazy factory choices shape API contracts, error handling, and blocking-code integration? What patterns do you enforce?
answer
- type = published contract (cardinality honesty)
- ban Mono.just(blockingCall) -> fromCallable + subscribeOn(boundedElastic)
- defer = retriable/fresh-per-subscription
- errors via onError, error(supplier) to defer throwable
- no collectList on unbounded; never block() the event loop
basics
~20 sType return values by true cardinality (Mono for 0..1, Flux for 0..N) so contracts are honest. Ban Mono.just around blocking/side-effecting calls; require fromCallable+subscribeOn(boundedElastic) or defer for retriable/fresh work. Push errors into onError, avoid collectList on unbounded streams, and never block the event loop.
solid answer
~50 sI enforce a few invariants. (1) **Honest cardinality**: return `Mono<T>` for single results and `Flux<T>` for streams; a `Flux` that always emits one item, or a `Mono<List<T>>` where a streaming `Flux<T>` belongs, are both smells — the type is the contract callers reason about. (2) **No eager side effects in `just`**: `Mono.just(blockingCall())` runs on the event loop and leaks exceptions outside the signal path; require `Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())` for blocking code and `Mono.defer(...)` for anything that must be fresh per subscription or genuinely retriable. (3) **Errors as signals**: failures flow through `onError`, not thrown at assembly; `error(supplier)` to defer construction. (4) **Bounded materialization**: forbid `collectList()` on unbounded/streaming sources; prefer streaming operators and backpressure. (5) **Never block the event loop** — `block()` in request handlers is banned. These make pipelines lazy, composable, retry-safe, and non-blocking end to end.
code
java · 23 linesimport reactor.core.publisher.Mono;
import reactor.core.publisher.Flux;
import reactor.core.scheduler.Schedulers;
// SMELL: streaming source collapsed into a buffered Mono<List>
Mono<List<Row>> bad = rowRepo.findAll().collectList(); // materializes all
// BETTER: keep it a Flux, stream with backpressure, low time-to-first-byte
Flux<Row> good = rowRepo.findAll();
// SMELL: blocking call eager on the event loop, exception escapes signal path
Mono<Report> badReport = Mono.just(legacy.blockingBuild());
// FIX: deferred, offloaded, retriable, errors -> onError
Mono<Report> goodReport =
Mono.fromCallable(() -> legacy.blockingBuild())
.subscribeOn(Schedulers.boundedElastic())
.retryWhen(reactor.util.retry.Retry.backoff(3, java.time.Duration.ofMillis(200)));
// Honest cardinality in a WebFlux handler
@GetMapping("/orders/{id}/items")
Flux<LineItem> items(@PathVariable String id) {
return orderRepo.findById(id) // Mono<Order> (0..1)
.flatMapMany(o -> itemRepo.findByOrder(o.id())); // -> Flux (0..N)
}go deeper
Not expected at this depth; may know just-vs-fromCallable at most.
Can identify the blocking-in-just anti-pattern and honest cardinality.
Explains subscribeOn(boundedElastic), error-signal composition, and collectList hazards concretely.
Frames all of it as enforceable API/contract policy, weighs Mono<List> vs Flux trade-offs, and operationalizes enforcement (review rules, BlockHound, backpressure).
## The lead's lens: the type IS the contract In a reactive codebase, `Mono` vs `Flux` isn't a stylistic pick — it's a **published contract** every caller composes against. Reviewing WebFlux code, I gate on a handful of load-bearing rules rooted in this leaf's mechanics. ### 1. Cardinality honesty - `Mono<T>` for **0..1**: findById, save-one, count (`Mono<Long>`), exists (`Mono<Boolean>`), delete (`Mono<Void>`). - `Flux<T>` for **0..N**: queries, streams, SSE, R2DBC row sets. - **Anti-patterns**: a `Flux<User>` for a by-id lookup (over-promises, forces `.next()`/`.single()` downstream); a `Mono<List<User>>` where a `Flux<User>` should stream (defeats backpressure, materializes memory, delays first byte). Choosing `Mono<List<T>>` vs `Flux<T>` is a real design decision: `Flux<T>` streams incrementally with backpressure and low latency-to-first-item; `Mono<List<T>>` is simpler for small bounded sets and transactional "all-or-nothing" semantics but buffers everything. ### 2. Eager vs lazy: ban side effects in `just` The single most common WebFlux bug I reject: ```java // REJECTED: blocking call on the event-loop thread, exception escapes signal path Mono<Data> m = Mono.just(repository.blockingLoad(id)); ``` `just` evaluates its argument **at assembly time**, synchronously, on whatever thread built the chain — in WebFlux that's often a Netty **event-loop** thread, and blocking it starves all other requests. The fix encodes intent: ```java Mono<Data> m = Mono.fromCallable(() -> repository.blockingLoad(id)) .subscribeOn(Schedulers.boundedElastic()); ``` `fromCallable` defers to subscription and captures thrown exceptions as `onError`; `subscribeOn(boundedElastic())` offloads blocking work to a dedicated, bounded elastic pool so the event loop stays free. For anything that must be **re-evaluated per subscription** (retries, per-request context, time/random), require `Mono.defer(...)` — because `.retry()`/`.repeat()` re-subscribe, and only a deferred source actually re-attempts the work. ### 3. Errors are signals, not exceptions Failures must travel through `onError`, composable with `onErrorResume`, `onErrorMap`, `retryWhen`. `Mono.error(new Ex())` still builds the exception eagerly at assembly; for hot paths or per-subscriber errors use the supplier overload `Mono.error(() -> new Ex())` or wrap in `defer`. Throwing raw exceptions inside operator lambdas is fine (Reactor converts them to `onError`), but **eagerly** throwing during assembly (e.g. inside a `just` argument) bypasses the pipeline's error handling. ### 4. Bounded materialization and backpressure `collectList()` buffers the entire Flux and only emits on completion: unusable on infinite streams (hangs) and dangerous on large result sets (OOM). I require streaming operators (`flatMap` with concurrency limits, `buffer`/`window` for batching, `take(n)`) and honor backpressure end-to-end. `Flux.fromIterable` on a huge in-memory collection is itself a smell — the data is already fully materialized upstream. ### 5. Never block the reactive thread `block()`, `blockFirst()`, `blockLast()`, and `toIterable()` are banned inside request handling — they defeat the entire non-blocking model and can deadlock on limited event-loop pools. Reactor's `BlockHound` can be wired in tests to catch accidental blocking calls on non-blocking schedulers. ### 6. Composition hygiene - Prefer `flatMapMany` for Mono->Flux fan-out; `mono.flux()` only for pure retyping. - `flatMap` on a Mono keeps 0..1 — reviewers catch cases where the inner emits many and only the first is silently kept. - Use `then()`/`thenMany()` to sequence side effects and signal completion cleanly. ## Why it compounds These rules aren't pedantry: honest cardinality makes downstream composition type-safe; lazy factories make pipelines retry-safe and non-blocking; signal-based errors make failures composable; bounded materialization preserves the memory/latency wins that justified going reactive in the first place. Violate any one and you get a codebase that is reactive in type only — blocking, eager, and memory-hungry underneath.
- When is Mono<List<T>> actually the right choice over Flux<T>?When the set is small and bounded, the caller needs the whole collection atomically (e.g. transactional all-or-nothing, or an aggregate computed over the full list), or the downstream API genuinely needs a List. For large or streaming data, Flux<T> is superior because it streams incrementally with backpressure and yields a low time-to-first-item instead of buffering everything.
- How would you catch accidental blocking calls sitting on the event loop in a reactive service?Enforce fromCallable/subscribeOn(boundedElastic) for known blocking integrations in review, and wire Reactor BlockHound into the test/dev runtime — it instruments the JVM to throw when a blocking call executes on a non-blocking scheduler thread, surfacing violations before production.
- Why does error(supplier) matter over error(exception) at scale?error(new Ex()) constructs the throwable eagerly at assembly time even if no one subscribes, and shares one instance/stacktrace across subscribers. The supplier overload defers construction to subscription, so the exception (and its stack capture cost) is created only when actually needed and freshly per subscriber.
saying these in an interview costs you the question
- Returning Mono<List<T>> for large or streaming data instead of Flux<T>
- Allowing Mono.just around blocking or side-effecting calls
- Using block()/blockFirst() inside request handlers
- Treating cardinality typing as cosmetic rather than a caller-facing contract
- Using collectList() on unbounded streams
- Assuming subscribeOn alone makes truly blocking code safe without a bounded elastic pool