Fluent API Field Guide
Decision guide, idiom catalogue, performance notes, and pitfalls
- 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));
}
- Martin Fowler: Fluent Interface - The original pattern description
- Haskell Lens: Lens Tutorial - Deeper theoretical understanding
Practice the fluent API in Tutorial 09: Fluent Optics API (7 exercises, ~10 minutes).
Next Steps:
- Free Monad DSL for Optics - Build composable programs
- Optic Interpreters - Multiple execution strategies
- Advanced Patterns - Complex real-world scenarios
Previous: Fluent API Next: Integration and Recipes