Spring WebFlux Integration

The fun-spring-webflux module maps dmx-fun outcomes — Option, Result, Try, and Validated — to Spring WebFlux responses, so a handler returns a domain outcome instead of re-implementing status/body conventions per service. Use WebfluxFun for functional endpoints (ServerResponse) or WebfluxEntity for annotation controllers (ResponseEntity).

It is an optional module. spring-webflux is declared compileOnly (your app already brings Spring), and it builds on fun-reactor. The core fun library keeps no dependency on Spring.

Adding the dependency

// Gradle
implementation("codes.domix:fun-spring-webflux:LATEST_VERSION")
// Spring WebFlux — bring your own version (6.0.x – 7.0.x)
implementation("org.springframework:spring-webflux:7.0.8")
<!-- Maven -->
<dependency>
<groupId>codes.domix</groupId>
<artifactId>fun-spring-webflux</artifactId>
<version>LATEST_VERSION</version>
</dependency>
<!-- Spring WebFlux — bring your own version (6.0.x – 7.0.x) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>7.0.8</version>
</dependency>

fun-spring-webflux declares spring-webflux as compileOnly and depends on fun-reactor. Your WebFlux application already brings Spring on the classpath, so the adapter never forces a version.

The WebfluxFun adapters

Each adapter takes the upstream reactive outcome and returns a Mono<ServerResponse>.

Adapter Behavior
fromResult(mono, errorMapper) Ok200 + body; ErrerrorMapper; empty → 404
fromOption(mono) Some200 + body; None or empty → 404
fromTry(mono, failureMapper) Success200 + body; FailurefailureMapper; empty → 404
fromValidated(mono) Valid200 + body; Invalid400 with all errors; empty → 404
fromResultStream(flux, errorMapper) aggregate a Flux<Result> (fail-fast); all Ok200 + list; first ErrerrorMapper
fromResultStreamAccumulating(flux) aggregate a Flux<Result> accumulating; all Ok200 + list; any Err400 with every error
stream(flux, mediaType, type) stream a Flux<V> as the body (NDJSON / SSE); 200, each element encoded as it arrives

The success branch defaults to 200 OK. To return a different status or headers (e.g. 201 Created + Location), fromResult, fromOption, fromTry, and fromValidated each have an overload taking a SuccessHttpMapper<V> — see Customizing the success response.

RouterFunction<ServerResponse> routes() {
return RouterFunctions.route()
.GET("/users/{id}", request ->
WebfluxFun.fromResult(
userService.findById(request.pathVariable("id")), // Mono<Result<User, ApiError>>
error -> ServerResponse.status(error.status()).bodyValue(error.detail())))
.GET("/profiles/{id}", request ->
WebfluxFun.fromOption(profileService.find(request.pathVariable("id")))) // Mono<Option<Profile>>
.build();
}

HTTP mapping defaults

  • Result.Ok(v) / Try.Success(v) / Option.Some(v) / Validated.Valid(v)200 OK with v as the body
  • Option.None404 Not Found
  • Validated.Invalid(errors)400 Bad Request with the accumulated errors as the body
  • Result.Err(e) → your ErrorHttpMapper<E>; Try.Failure(t) → your ThrowableHttpMapper
  • an empty source Mono (it completes without emitting an outcome) → 404 Not Found

The success body is written with ServerResponse.ok().bodyValue(v), so the value must be encodable by your WebFlux codecs.

Customizing the success response (201 Created, headers)

The default success branch is 200 OK with the value as the body. When you need a different status or headers — 201 Created with a Location on a POST, 202 Accepted, caching headers — pass a SuccessHttpMapper<V> to the overload of fromResult, fromOption, fromTry, or fromValidated. It controls the Ok/Some/Success/Valid branch; the failure branches are unchanged.

.POST("/orders", request ->
WebfluxFun.fromResult(
orderService.place(request), // Mono<Result<Order, ApiError>>
order -> ServerResponse.created(location(order)) // Ok -> 201 Created + Location
.bodyValue(order),
error -> ServerResponse.status(error.status()).bodyValue(error.detail())))

SuccessHttpMapper<V> mirrors ErrorHttpMapper<E> and ThrowableHttpMapper — it maps a success value to any Mono<ServerResponse> you build.

Annotation controllers (WebfluxEntity / ResponseEntity)

WebfluxFun targets functional endpoints (ServerResponse). For @RestController methods, WebfluxEntity is the counterpart: each adapter returns a Mono<ResponseEntity<…>> you return straight from the handler, applying the same HTTP conventions.

@GetMapping("/users/{id}")
Mono<ResponseEntity<User>> user(@PathVariable String id) {
return WebfluxEntity.fromOption(userService.find(id)); // Some -> 200, None/empty -> 404
}
@GetMapping("/orders/{id}")
Mono<ResponseEntity<?>> order(@PathVariable String id) {
return WebfluxEntity.fromResult(orderService.findById(id), // Mono<Result<Order, ApiError>>
error -> ResponseEntity.status(error.status()).body(error.detail()));
}
Adapter Behavior
fromOption(mono) Some200 + body; None or empty → 404
fromResult(mono, onError) Ok200 + body; ErronError; empty → 404
fromTry(mono, onFailure) Success200 + body; FailureonFailure; empty → 404
fromValidated(mono) Valid200 + body; Invalid400 with all errors; empty → 404

fromOption preserves the body type (Mono<ResponseEntity<V>>); the others return Mono<ResponseEntity<?>> because the success and error branches carry different body types. The error/failure mapper builds any ResponseEntity you like (status, headers, body).

Validating a request body → 400 with every error

Validated accumulates errors, and fromValidated turns an Invalid into a single 400 carrying all of them — no submit-three-times round-trips:

.POST("/signup", request ->
request.bodyToMono(SignupForm.class)
.map(form -> validate(form)) // Validated<NonEmptyList<String>, User>
.flatMap(validated -> WebfluxFun.fromValidated(Mono.just(validated))))

RFC 7807 problem responses

To render failures as standard RFC 7807 ProblemDetail documents (application/problem+json) instead of an ad-hoc body, use WebfluxProblem. Its problemDetail factories return an ErrorHttpMapper / ThrowableHttpMapper you hand to the regular adapters:

// Result.Err -> problem+json with a status and a detail derived from the error
.GET("/users/{id}", request ->
WebfluxFun.fromResult(userService.findById(request.pathVariable("id")),
WebfluxProblem.problemDetail(HttpStatus.NOT_FOUND, ApiError::message)))
// Try.Failure -> problem+json with the exception message as the detail
.POST("/orders", request ->
WebfluxFun.fromTry(orderService.place(request),
WebfluxProblem.problemDetail(HttpStatus.INTERNAL_SERVER_ERROR)))

For accumulated validation, WebfluxProblem.fromValidated renders an Invalid as a 400 problem whose errors property lists every violation:

.POST("/signup", request ->
WebfluxProblem.fromValidated(
request.bodyToMono(SignupForm.class).map(form -> validate(form)))) // Mono<Validated<…>>
// 400 application/problem+json — {"status":400,"detail":"Validation failed","errors":[...]}

Spring Boot auto-configuration

With fun-spring-boot on the classpath, a default ThrowableHttpMapper bean (dmxFunProblemDetailMapper) is auto-configured — a ready-made RFC 7807 renderer you can inject and reuse instead of building one per handler:

RouterFunction<ServerResponse> routes(ThrowableHttpMapper problems) { // injected
return RouterFunctions.route()
.POST("/orders", request -> WebfluxFun.fromTry(orderService.place(request), problems))
.build();
}

The status defaults to 500 (set dmx.fun.webflux.problem.status), the bean backs off if you declare your own ThrowableHttpMapper, and it can be disabled with dmx.fun.webflux.problem.enabled=false. The adapters themselves stay static — this only provides the default bean.

Aggregating a stream

fromResultStream sequences a Flux<Result<V, E>> fail-fast (via fun-reactor’s ReactorFlux.sequence) into one response: all Ok become a 200 with the list, and the first Err is rendered by your errorMapper.

.GET("/orders", request ->
WebfluxFun.fromResultStream(orderService.findAll(query), // Flux<Result<Order, ApiError>>
error -> ServerResponse.status(error.status()).bodyValue(error.detail())))

To report every failure instead of stopping at the first, use the accumulating variant — all Ok become a 200 with the list, any Err makes a 400 carrying all the accumulated errors (backed by fun-reactor’s ReactorFlux.collectValidated):

.GET("/orders", request ->
WebfluxFun.fromResultStreamAccumulating(orderService.findAll(query))) // Flux<Result<Order, ApiError>>

Streaming a response body

When the response should stream element-by-element rather than collect first, stream renders a Flux<V> as the body with a chosen content type (NDJSON or SSE):

.GET("/orders/stream", request ->
WebfluxFun.stream(orderService.findAll(), MediaType.APPLICATION_NDJSON, Order.class))

Because the 200 status and headers are sent before the body streams, a terminal error cannot change the status. The four-argument overload appends a typed fallback element on error instead of abruptly closing the connection:

WebfluxFun.stream(orders, MediaType.APPLICATION_NDJSON, Order.class,
throwable -> Order.unavailable()); // graceful last element on failure

Composing before responding

When the upstream is a Mono<Result<V, E>>, use fun-reactor’s ReactorResult operators (mapOk, flatMapOk, mapErr, recover) to stay on the Result track, then hand the result to fromResult for the HTTP mapping.

Version compatibility

Tested in CI against the following Spring Framework versions on every pull request that touches the spring-webflux module:

Spring version Status
6.0.x tested
6.1.x tested
6.2.x tested
7.0.x tested

To test locally against a specific version:

Terminal window
./gradlew :frameworks:spring-webflux:test -PspringVersion=7.0.8