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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions