Reactor to Mutiny: the operator phrasebook

Update, 2026-10-19: The retry row now follows what the benchmark repo settled in August 2026: Micronaut's attempts counts retries, not calls, so attempts = "2" and Mutiny's maxRetries = 2 are both three calls. Two ways to mistranslate a retry has the details.

Assumes you've written reactive Java with Project Reactor (Mono/Flux). Familiarity with Quarkus helpful but not required.


This is the document I kept open in a split pane while porting a 28-API concurrent fan-out from Micronaut/Reactor to Quarkus/Mutiny. Both versions compile, run, hit the same mock server, persist to Postgres, and pass the same test shapes. The translation is mechanical until it isn't.

The phrasebook#

ReactorMutinyIdentical semantics?
Mono<T>Uni<T>Yes
Flux<T>Multi<T>Yes
.map(f).onItem().transform(f)Yes
.flatMap(f).onItem().transformToUni(f)Yes
.doOnNext(c).onItem().invoke(c)Yes
.onErrorReturn(x).onFailure().recoverWithItem(x)Yes
Mono.zip(a, b, ..., h)Uni.combine().all().unis(a, b, ..., h).asTuple()Yes*
.getT1() .. .getT8().getItem1() .. .getItem8()Yes
.timeout(duration).ifNoItem().after(duration).fail()Yes
repo.save(e).thenReturn(r)entity.persist().replaceWith(r)No**

* Typed tuples stop at 8 in Reactor (Mono.zip up to Tuple8) and at 9 in Mutiny (unis(...) up to nine arguments, Tuple9). Past that, both take a list or array plus a combinator: Reactor's Mono.zip(Function<Object[], R>, Mono<?>...) or its Iterable form, Mutiny's unis(Iterable) with .with(...). See below for how the 28-call fan-out got around it.

** The persist line looks like a rename but it's a paradigm shift. Micronaut Data uses a repository interface (ReactorCrudRepository<T, ID>) and save() returns Mono<T>. Quarkus Panache uses active record — the entity extends PanacheEntityBase, you call entity.persist(), and annotate the calling method @WithTransaction. The entity is the repository.

The Mutiny API reads like someone translated Reactor's method names into full English sentences. onErrorReturn becomes onFailure().recoverWithItem(). Thirteen characters become twenty-nine. Every single-method Reactor operator becomes a fluent chain. You get used to it.

Hard case 1: nested zip beyond Tuple8#

The Reactor original combined 28 concurrent API calls by grouping them into four blocks (three Tuple8s and one Tuple4), then nesting the zip:

// Reactor original: nested zip
return Mono.zip(block1, block2).zipWith(Mono.zip(block3, block4))
        .flatMap(outer -> {
            // outer is Tuple2<Tuple2<Tuple8, Tuple8>, Tuple2<Tuple8, Tuple4>>
            Tuple2<Tuple8<...>, Tuple8<...>> left = outer.getT1();
            Tuple2<Tuple8<...>, Tuple4<...>> right = outer.getT2();

            SolarFlareResponse flare = left.getT1().getT1();
            CmeResponse cme = left.getT1().getT2();
            // ... 26 more field extractions through .getT1().getT3(), .getT2().getT7(), etc.
        });

Tuple2<Tuple2<Tuple8, Tuple8>, Tuple2<Tuple8, Tuple4>>. Read that type signature aloud at your next standup if you want the room to yourself.

Mutiny has no zipWith. The temptation is to panic. Don't: flattening to one four-way combine is simpler and behaves the same:

// Mutiny: flat 4-way combine
return Uni.combine().all().unis(block1, block2, block3, block4).asTuple()
        .onItem().transformToUni(outer -> {
            // outer is Tuple4<Tuple8, Tuple8, Tuple8, Tuple4>
            Tuple8<...> b1 = outer.getItem1();
            Tuple8<...> b2 = outer.getItem2();
            Tuple8<...> b3 = outer.getItem3();
            Tuple4<...> b4 = outer.getItem4();

            SolarFlareResponse flare = b1.getItem1();
            CmeResponse cme = b1.getItem2();
            // ... same 26 fields, but one fewer nesting level
        });

Why this is safe: every leaf Uni is pre-recovered with a fallback (onFailure().recoverWithItem(EMPTY_SOLAR_FLARE) etc.), so nothing fails into the combine. All 28 calls subscribe concurrently regardless of nesting depth — both Reactor and Mutiny subscribe to all arguments of a zip/combine eagerly. The flatten just drops one indirection layer. The field extraction indices map 1:1 because block1 through block4 are identical in both versions.

The actual diff for the field access is left.getT1().getT1() becoming b1.getItem1(). Less nesting, same data. Reactor's typed Mono.zip(block1, block2, block3, block4) returns a Tuple4 just as happily, so the original could have flattened the same way. It nested by choice; the port was simply the moment someone had to read that type out loud.

Hard case 2: per-call timeout#

The health check pings 28 APIs with a 2-second timeout each. In Reactor, timeout() is a single operator that converts to TimeoutException on the error channel:

// Reactor
private <T> Mono<String> check(Mono<T> source) {
    return source.timeout(TIMEOUT)
            .map(r -> "UP")
            .onErrorReturn("DOWN");
}

Mutiny has no .timeout() operator. Instead, timeout is expressed as a conditional failure:

// Mutiny
private <T> Uni<String> check(Uni<T> source) {
    return source.ifNoItem().after(TIMEOUT).fail()
            .onItem().transform(r -> "UP")
            .onFailure().recoverWithItem("DOWN");
}

Read it aloud: "if no item after 2 seconds, fail." Then the failure recovery catches both the timeout and any upstream HTTP error identically. The semantics are the same — the Mutiny version just makes you spell out what Reactor's timeout() does internally.

Both approaches cancel the upstream subscription on timeout. Both let .recoverWithItem("DOWN") serve as the universal fallback for timeouts and connection errors and 500s. One method, three failure modes handled.

Hard case 3: the testing trap#

No operator involved here: a scope annotation that will cost you an hour if you don't know about it.

Start with the import. The old io.quarkus.test.junit.mockito.InjectMock is gone: quarkus-junit5-mockito 3.20.0 still ships it, 3.27.0 no longer does. Its replacement, io.quarkus.test.InjectMock in quarkus-test-common, was already there in 3.2.0.Final, so older tutorials still compile on old Quarkus and break on current:

// Old (absent from quarkus-junit5-mockito 3.27.0 on)
import io.quarkus.test.junit.mockito.InjectMock;

// Current (quarkus-test-common, 3.2.0.Final onward)
import io.quarkus.test.InjectMock;

But the import is the easy part. @InjectMock requires a CDI normal scope on the target bean. If your service uses @Singleton (which works fine for the running app), the mock injection fails:

the injected bean does not declare a CDI normal scope

Fix: @ApplicationScoped instead of @Singleton on every service you want to mock. For this app they behave the same at runtime. The difference is the client proxy: @ApplicationScoped gets one, @Singleton doesn't, and @InjectMock swaps the mock in through that proxy, so by default it accepts only the normal-scoped bean.

Mock return values swap Mono.just(...) for Uni.createFrom().item(...):

// Reactor / Micronaut
when(service.execute()).thenReturn(Mono.just(response));

// Mutiny / Quarkus
when(service.execute()).thenReturn(Uni.createFrom().item(response));

What doesn't change#

The AtomicInteger success counter in the 28-way fan-out? Identical. Both Reactor and Mutiny invoke .onItem().invoke(r -> successes.incrementAndGet()) / .doOnNext(r -> successes.incrementAndGet()) on the same thread model — the counter works because each leaf completes exactly once.

All business logic — risk score computation, cross-correlation checks, response construction — copies verbatim. The reactive wrapper changes; the computation inside flatMap / transformToUni doesn't.

All DTO records survive with only annotation swaps (Micronaut @Serdeable goes away; Quarkus/Jackson needs nothing on records).

The non-obvious client translation#

Strictly a framework translation rather than a Reactor-to-Mutiny one, and it blocks the port if you don't see it coming.

Micronaut declarative HTTP clients:

@Client("science")
@Retryable(attempts = "2", delay = "500ms")
public interface SolarClient {
    @Get("/api/v1/solar-flares")
    Mono<SolarFlareResponse> solarFlares();
}

Quarkus MicroProfile REST clients:

@RegisterRestClient(configKey = "science")
@Retry(maxRetries = 2, delay = 500, delayUnit = ChronoUnit.MILLIS)
public interface SolarClient {
    @GET
    @Path("/api/v1/solar-flares")
    Uni<SolarFlareResponse> solarFlares();
}

The method signature is identical: same name, same path, same return shape. The annotation set is entirely different. @Client becomes @RegisterRestClient. @Get (Micronaut) becomes @GET @Path (JAX-RS). @Retryable (Micronaut) becomes @Retry (MicroProfile Fault Tolerance). Injection switches from constructor injection to @RestClient qualifier on the constructor parameter.

The retry number carries over unchanged, and that is the trap: it looks wrong. Micronaut's attempts reads like a total, but its Javadoc says "The maximum number of retry attempts", and SimpleRetry.canRetry allows maxAttempts + 1 calls. attempts = "2" is one call plus two retries. MicroProfile's maxRetries also counts only the retries after the first call, so maxRetries = 2 is the same three calls. A later review read attempts as a total and "fixed" the port to maxRetries = attempts - 1, leaving every client one call short of the original until the arithmetic was checked against SimpleRetry. Two ways to mistranslate a retry has the whole story.

The persist line#

One more, because it looks trivial and isn't:

// Reactor / Micronaut Data
return repository.save(entity).thenReturn(response);

// Mutiny / Quarkus Panache
return entity.persist().replaceWith(response);

The repository interface disappears. SurveyResultRepository extends ReactorCrudRepository<SurveyResult, Long> has no Panache equivalent — the entity is the repository. persist() returns Uni<SurveyResult>, and replaceWith(response) discards the persisted entity in favor of the response you already built. The calling method needs @WithTransaction from io.quarkus.hibernate.reactive.panache.common.

thenReturn becomes replaceWith. Same semantics: "persist this, but return that." The operator name changes because Mutiny doesn't have thenReturn — and honestly, replaceWith is a better name for what's happening.

The whole mapping, for your split pane#

Bookmark this. You'll need it.

WhatReactor (Micronaut)Mutiny (Quarkus)
Single valueMono<T>Uni<T>
StreamFlux<T>Multi<T>
Transform.map(f).onItem().transform(f)
Async transform.flatMap(f).onItem().transformToUni(f)
Side effect.doOnNext(c).onItem().invoke(c)
Error fallback.onErrorReturn(x).onFailure().recoverWithItem(x)
Combine NMono.zip(a..h)Uni.combine().all().unis(a..h).asTuple()
Tuple access.getT1().getItem1()
Timeout.timeout(d).ifNoItem().after(d).fail()
Discard & return.thenReturn(x).replaceWith(x)
Create from valueMono.just(x)Uni.createFrom().item(x)
Service scope@Singleton@ApplicationScoped
HTTP client@Client("id")@RegisterRestClient(configKey="id")
Retry (both count retries)@Retryable(attempts="2")@Retry(maxRetries=2)
GET endpoint@Get("/path")@GET @Path("/path")
DB persistrepo.save(e)e.persist()
Transactionimplicit@WithTransaction
Mock returnMono.just(x)Uni.createFrom().item(x)
Mock scope reqnone@ApplicationScoped (not @Singleton)

Filed under: java, quarkus, reactor, mutiny

Sources#