Fluent API Field Guide

Decision guide, idiom catalogue, performance notes, and pitfalls

What You'll Learn

  • When to reach for the static-method style versus the fluent-builder style.
  • Common patterns and idioms (composition shortcuts, conditional updates, error handling).
  • Performance considerations and integration tactics for existing Java codebases.
  • Common pitfalls and how to avoid them.

This page is the lookup shelf for the Fluent API. The narrative explanation, side-by-side comparison, and worked examples live in Fluent API; use this page when you already know the style choices and need a quick answer.


When to Use Each Style

Use Static Methods When:

Performing simple, one-off operations

// Clear and concise
String name = OpticOps.get(person, PersonLenses.name());

Chaining is not needed

// Direct transformation
Person older = OpticOps.modify(person, PersonLenses.age(), a -> a + 1);

Performance is critical (slightly less object allocation)

Use Fluent Builders When:

Building complex workflows

import static java.util.stream.Collectors.toList;

// Clear intent at each step
return OpticOps.getting(order)
    .allThrough(OrderTraversals.items())
    .stream()
    .filter(item -> item.quantity() > 10)
    .map(OrderItem::productId)
    .collect(toList());

IDE autocomplete is important (great for discovery)

Code reviews matter (explicit intent)

Teaching or documentation (self-explanatory)


Common Patterns and Idioms

Pattern 1: Pipeline Transformations

// Sequential transformations for multi-step pipeline
// Note: Result and Data should be your application's domain types with appropriate lenses
Result processData(Data input) {
    Data afterStage1 = OpticOps.modifying(input)
        .through(DataLenses.stage1(), this::transformStage1);

    Data afterStage2 = OpticOps.modifying(afterStage1)
        .through(DataLenses.stage2(), this::transformStage2);

    return OpticOps.modifying(afterStage2)
        .through(DataLenses.stage3(), this::transformStage3);
}

Pattern 2: Conditional Updates

// Static style for simple conditionals
Person updateIfAdult(Person person) {
    int age = OpticOps.get(person, PersonLenses.age());
    return age >= 18
        ? OpticOps.set(person, PersonLenses.status(), "ADULT")
        : person;
}

Pattern 3: Bulk Operations with Filtering

// Combine both styles for clarity
Team updateTopPerformers(Team team, int threshold) {
    // Use fluent for query
    List<Player> topPerformers = OpticOps.querying(team)
        .allThrough(TeamTraversals.players())
        .stream()
        .filter(p -> p.score() >= threshold)
        .toList();

    // Use static for transformation
    return OpticOps.modifyAll(
        team,
        TeamTraversals.players(),
        player -> topPerformers.contains(player)
            ? OpticOps.set(player, PlayerLenses.status(), "STAR")
            : player
    );
}

Performance Considerations

Object Allocation

  • Static methods: Minimal allocation (just the result)
  • Fluent builders: Create intermediate builder objects
  • Impact: Negligible for most applications; avoid in tight loops

Optic Composition

Both styles benefit from composing optics once and reusing them:

// Good: Compose once, use many times
Lens<Order, BigDecimal> orderToTotalPrice =
    OrderTraversals.items()
        .andThen(OrderItemLenses.price().asTraversal())
        .andThen(someAggregationLens);

orders.stream()
    .map(order -> OpticOps.getAll(order, orderToTotalPrice))
    .collect(toList());

// Avoid: Recomposing in loop
orders.stream()
    .map(order -> OpticOps.getAll(
        order,
        OrderTraversals.items()
            .andThen(OrderItemLenses.price().asTraversal())  // Recomposed each time!
    ))
    .collect(toList());

Integration with Existing Java Code

Working with Streams

// Optics integrate naturally with Stream API
List<String> highScorerNames = OpticOps.getting(team)
    .allThrough(TeamTraversals.players())
    .stream()
    .filter(p -> p.score() > 90)
    .map(p -> OpticOps.get(p, PlayerLenses.name()))
    .collect(toList());

Working with Optional

// Optics and Optional work together
Optional<Person> maybePerson = findPerson(id);

Optional<Integer> age = maybePerson
    .map(p -> OpticOps.get(p, PersonLenses.age()));

Person updated = maybePerson
    .map(p -> OpticOps.modify(p, PersonLenses.age(), a -> a + 1))
    .orElse(new Person("Default", 0, "UNKNOWN"));

Common Pitfalls

Don't: Call get then set

// Inefficient - two traversals
int age = OpticOps.get(person, PersonLenses.age());
Person updated = OpticOps.set(person, PersonLenses.age(), age + 1);

Do: Use modify

// Efficient - single traversal
Person updated = OpticOps.modify(person, PersonLenses.age(), a -> a + 1);

Don't: Recompose optics unnecessarily

// Bad - composing in a loop
for (Order order : orders) {
    var itemPrices = OrderTraversals.items()
        .andThen(OrderItemLenses.price().asTraversal());  // Composed each iteration!
    process(OpticOps.getAll(order, itemPrices));
}

Do: Compose once, reuse

// Good - compose outside loop
var itemPrices = OrderTraversals.items()
    .andThen(OrderItemLenses.price().asTraversal());

for (Order order : orders) {
    process(OpticOps.getAll(order, itemPrices));
}

Further Reading

Hands-On Learning

Practice the fluent API in Tutorial 09: Fluent Optics API (7 exercises, ~10 minutes).


Next Steps:


Previous: Fluent API Next: Integration and Recipes