Skip to content

Add fluent tuple destructuring with with() and withUni() methods #2008

@hhfrancois

Description

@hhfrancois

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 flatMap calls
  • 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 SubscribableN interface has the correct typed signatures for N parameters
  • Each plugN() method is type-safe and specific to Uni<TupleN>
  • Wrappers implementing SubscribableN guarantee they expose properly typed with() and withUni() 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() through plug9()) to Uni interface

Implementation

I have a complete working implementation supporting Tuple2 through Tuple9 with both:

  • with(FunctionN<T1...TN, R>) - synchronous transformation
  • withUni(FunctionN<T1...TN, Uni<R>>) - asynchronous chaining (like flatMap)

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 FunctionN interfaces 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

  1. Readability: Lambda parameters are naturally named instead of tuple.getItem1()
  2. Type safety: Full generic inference, no casts needed
  3. Composability: Chainable like other Mutiny operators
  4. Familiar pattern: Similar to Uni.combine().all().unis(...).with()

Use Cases

  • Sequential composition with multiple intermediate values
  • Avoiding deeply nested flatMap chains
  • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions