-
Notifications
You must be signed in to change notification settings - Fork 137
Description
Problem
When working with Uni<TupleN> in reactive chains, destructuring tuples requires verbose and repetitive .getItemN() calls:
// Current approach - verbose and nested
return createUserAndToken(userId)
.flatMap(tuple -> {
User user = tuple.getItem1();
String token = tuple.getItem2();
return sendNotification(user, token)
.map(result -> new Response(user, token, result));
});This becomes increasingly difficult to read with:
- Multiple tuple items (Tuple3, Tuple4, etc.)
- Nested
flatMapcalls - Need to manually extract and name each item
Proposed Solution
Add helper methods to fluently destructure tuples directly in the reactive chain:
// Proposed API - clean and readable
// The method returns WithTuple2 directly, hiding the wrapper
private UniAndGroup2<User, String> createUserAndToken(String userId) {
return WithTuple.ofTuple2(
userRepo.findById(userId).flatMap(user -> tokenService.generate(user).map(token -> Tuple2.of(user, token)))
);
}
// Usage - looks like standard Mutiny API
return createUserAndToken(userId)
.withUni((user, token) -> {
sendNotification(user, token)
.map(result -> new Response(user, token, result))
});Compare with current verbose approach:
// Current: must use .flatMap() and .getItemN()
private Uni<Tuple2<User, String>> createUserAndToken(String userId) {
return userRepo.findById(userId)
.flatMap(user -> {
return tokenService.generate(user)
.map(token -> Tuple2.of(user, token));
});
}
// Usage - verbose destructuring
return createUserAndToken(userId)
.flatMap(tuple -> {
return sendNotification(tuple.getItem1(), tuple.getItem2())
.map(result -> new Response(tuple.getItem1(), tuple.getItem2(), result))
});Key insight: The wrapper is hidden in the method signature, so callers see a fluent API similar to Uni.combine().all().unis(...).with() without knowing it's a custom wrapper.
API Design Options
Option 1: Standalone utility class (works today, no breaking changes)
This approach requires no modifications to Mutiny core - it's a simple helper utility that can be added to any project:
public class WithTuple {
public static <T1, T2> WithTuple2<T1, T2> ofTuple2(Uni<Tuple2<T1, T2>> tupleUni) { ... }
}
// Usage
return WithTuple.ofTuple2(createUserAndToken(userId))
.withUni((user, token) -> ...);
// with
Uni<Tuple2> createUserAndToken...
// OR
createUserAndToken(userId).withUni((user, token) -> ...);
// with
WithTuple.WithTuple2 createUserAndToken(...) {
return WithTuple.ofTuple2( ... );
}Advantages:
- ✅ Works immediately with current Mutiny version
- ✅ No breaking changes to Mutiny API
- ✅ Can be provided as external helper library
- ✅ Easy to adopt and test
Option 2: Integrate with .plug() via Subscribable interfaces
Currently .plug() forces the return type to be Uni<R>. This proposal suggests allowing .plug() to return a wrapper type R that implements a Subscribable interface exposing with() and withUni():
Current limitation:
// plug() MUST return Uni<R> - you can't return a wrapper
<R> Uni<R> plug(Function<Uni<T>, Uni<R>> operator);Proposed generalization:
// Define interfaces for each arity (2 through 9 parameters)
interface Subscribable2<T1, T2> {
<R> Uni<R> with(BiFunction<T1, T2, R> mapper);
<R> Uni<R> withUni(BiFunction<T1, T2, Uni<R>> mapper);
}
interface Subscribable3<T1, T2, T3> {
<R> Uni<R> with(TriFunction<T1, T2, T3, R> mapper);
<R> Uni<R> withUni(TriFunction<T1, T2, T3, Uni<R>> mapper);
}
// ... similar for Subscribable4 through Subscribable9
// Add corresponding plug() methods on Uni for each arity
interface Uni<T> {
// Existing
<R> Uni<R> plug(Function<Uni<T>, Uni<R>> operator);
// New overloads for subscribable wrappers
<T1, T2, R extends Subscribable2<T1, T2>> R plug2(Function<Uni<Tuple2<T1, T2>>, R> operator);
<T1, T2, T3, R extends Subscribable3<T1, T2, T3>> R plug3(Function<Uni<Tuple3<T1, T2, T3>>, R> operator);
// ... similar plug4 through plug9
}Rationale:
- Each
SubscribableNinterface has the correct typed signatures for N parameters - Each
plugN()method is type-safe and specific toUni<TupleN> - Wrappers implementing
SubscribableNguarantee they expose properly typedwith()andwithUni()methods
Example usage with plug2():
// Helper method that hides the wrapper - returns the result of plug2()
private WithTuple.WithTuple2<User, String> createUserAndToken(String userId) {
return userRepo.findById(userId)
.flatMap(user -> {
return tokenService.generate(user)
.map(token -> Tuple2.of(user, token));
})
.plug2(WithTuple::ofTuple2); // Type-safe method reference!
}
// Usage - caller doesn't see plug2() or WithTuple, just the fluent API
return createUserAndToken(userId)
.withUni((user, token) -> {
return sendNotification(user, token)
.map(result -> new Response(user, token, result));
});Advantages:
- ✅ Seamless integration with existing
.plug()pattern - ✅ Can use method references (
WithTuple::ofTuple2) - ✅ More discoverable (appears in IDE autocomplete on
Uni<TupleN>)
Disadvantages:
- ❌ Requires modifications to Mutiny core
- ❌ Adds 8 new methods (
plug2()throughplug9()) toUniinterface
Implementation
I have a complete working implementation supporting Tuple2 through Tuple9 with both:
with(FunctionN<T1...TN, R>)- synchronous transformationwithUni(FunctionN<T1...TN, Uni<R>>)- asynchronous chaining (likeflatMap)
Full implementation: See attached WithTuple.java
Key features:
- ✅ Type-safe destructuring with full generic inference
- ✅ Supports all tuple sizes (2-9)
- ✅ Zero blocking - fully reactive
- ✅ Custom
FunctionNinterfaces for N>3 parameters
Example Code
public class WithTuple {
@FunctionalInterface
public interface Function4<T1, T2, T3, T4, R> {
R apply(T1 t1, T2 t2, T3 t3, T4 t4);
}
public static class WithTuple2<T1, T2> {
private final Uni<Tuple2<T1, T2>> tupleUni;
private WithTuple2(Uni<Tuple2<T1, T2>> tupleUni) {
this.tupleUni = tupleUni;
}
public <R> Uni<R> with(BiFunction<T1, T2, R> combinator) {
return tupleUni.map(t -> combinator.apply(t.getItem1(), t.getItem2()));
}
public <R> Uni<R> withUni(BiFunction<T1, T2, Uni<R>> combinator) {
return tupleUni.flatMap(t -> combinator.apply(t.getItem1(), t.getItem2()));
}
}
public static <T1, T2> WithTuple2<T1, T2> ofTuple2(Uni<Tuple2<T1, T2>> tupleUni) {
return new WithTuple2<>(tupleUni);
}
// ... similar for Tuple3-Tuple9
}Usage Example:
WithTuple.WithTuple2<Geometry, Point> getMergedLineAndCrossPoint(LineString line1, LineString line2) {
return WithTuple.ofTuple2(
geodesicRepository.extendLinesToIntersection(line1, line2)
.flatMap(merged -> {
return extractCrossPoint(merged).map(crossPoint -> {
return Tuple2.of(merged, crossPoint);
});
});
);
}
// Usage
return getMergedLineAndCrossPoint(lineA, lineB)
.withUni((lineString, crossPoint) -> {
log.info("Found intersection at {}", crossPoint);
return processIntersection(lineString, crossPoint);
});Benefits
- Readability: Lambda parameters are naturally named instead of
tuple.getItem1() - Type safety: Full generic inference, no casts needed
- Composability: Chainable like other Mutiny operators
- Familiar pattern: Similar to
Uni.combine().all().unis(...).with()
Use Cases
- Sequential composition with multiple intermediate values
- Avoiding deeply nested
flatMapchains - Working with methods that naturally return tuples (e.g., "fetch user and their settings")
- Geodetic/geometric calculations returning multiple related values
Gist:
https://gist.github.com/hhfrancois/abc0e2303f82d112a823a580b71cd102