Fluent API Field Guide
Style choice, idiom catalogue, performance notes, and pitfalls
- When to reach for the static-method style and when for the fluent builders
- The idioms that recur: pipelines, conditional updates, filtered bulk operations
- How optics and the Stream API fit together
- The performance rule that actually matters, and the pitfalls worth knowing
This page is the lookup shelf for the Fluent API. The narrative, the side-by-side comparison and the worked validation examples live in Fluent API; come here when you already know the style choices and want a quick answer.
Which Style?
Static methods for short, one-off operations, where naming the operation twice would be noise:
String name = OpticOps.get(alice, PersonLenses.name());
Person older = OpticOps.modify(alice, PersonLenses.age(), a -> a + 1);
Fluent builders when the optic expression is long, when the call site is teaching material, or when you want the IDE to enumerate what is possible:
List<String> bigOrders =
OpticOps.getting(order).allThrough(OrderTraversals.items()).stream()
.filter(item -> item.quantity() > 10)
.map(OrderItem::productId)
.collect(toList());
Note which builder that is: getting(...) reads values out, so it is the one that returns a List you can stream. querying(...) answers questions (anyMatch, count, findFirst) and never hands you the whole collection, though findFirst returns at most one element.
Idioms
Pipeline Transformations
Each stage takes the previous result, so a multi-step transformation is a sequence of named locals rather than one unreadable expression:
Order discounted =
OpticOps.modifying(order).allThrough(Fixture.itemPrices, price -> price.multiply(new BigDecimal("0.9")));
Order rounded =
OpticOps.modifying(discounted)
.allThrough(Fixture.itemPrices, price -> price.setScale(2, java.math.RoundingMode.HALF_UP));
Conditional Updates
Read once, decide, then write. modify is not the tool when the decision depends on the value and the update targets a different field:
Person classified =
OpticOps.get(alice, PersonLenses.age()) >= 18
? OpticOps.set(alice, PersonLenses.status(), "ADULT")
: alice;
Bulk Operations, Narrowed by a Predicate
filtered narrows the traversal itself, so the update only reaches the elements that qualify and no membership test leaks into the modification function:
Traversal<Team, Player> topPerformers =
TeamTraversals.players().filtered(player -> player.score() >= 90);
Team starred =
Traversals.modify(
topPerformers.andThen(PlayerLenses.status().asTraversal()), status -> "STAR", team);
List<Player> stars = OpticOps.getAll(starred, topPerformers);
// read back from `starred`: the same players, now carrying status "STAR"
Aggregation
A Fold collapses every focused element through a Monoid, which is usually clearer than collecting to a list and reducing. A Traversal is not itself a Fold, so it converts with asFold():
int totalQuantity =
OrderTraversals.items()
.andThen(OrderItemLenses.quantity().asTraversal())
.asFold()
.foldMap(Monoids.integerAddition(), q -> q, order);
// 15
Working with Existing Java Code
Streams
Optics get the values out; the Stream API does the rest:
List<String> highScorerNames =
OpticOps.getting(team).allThrough(TeamTraversals.players()).stream()
.filter(p -> p.score() > 90)
.map(p -> OpticOps.get(p, PlayerLenses.name()))
.collect(toList());
Optional
Optional<Person> maybePerson = Fixture.findPerson("alice");
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("Unknown", 0, "UNKNOWN"));
Performance
Object allocation. Static methods allocate only the result. Builders allocate one short-lived builder as well. The difference is real and almost never the reason your code is slow; avoid builders in a tight inner loop and stop thinking about it.
Optic composition is the one that counts. Composing an optic walks the chain and builds new objects, so composing inside a loop repeats that work per iteration:
// Compose once, above the loop
Traversal<Order, BigDecimal> prices =
OrderTraversals.items().andThen(OrderItemLenses.price().asTraversal());
for (Order o : Fixture.orders) {
List<BigDecimal> values = OpticOps.getAll(o, prices);
}
Pitfalls
Do not get then set when you mean modify. Two traversals where one would do, and a race between them if anything else can touch the structure:
// Instead of get-then-set
Person older = OpticOps.modify(alice, PersonLenses.age(), a -> a + 1);
Do not recompose optics in a loop. As above: hoist the composition.
Do not reach for querying when you want the elements. querying answers anyMatch, allMatch, findFirst, count and isEmpty; findFirst is the only one that hands back a focused element, and at most one. To get them all, use getting(...).allThrough(...).
Do not expect an instance modify on a bare Traversal. Reads and writes go through the Traversals utility, or through OpticOps, or through a TraversalPath from the Focus DSL, which does carry getAll and modifyAll directly.
- Static for short, builders for long. They compile to the same operations, so the choice is about the reader.
gettingreturns values,queryingreturns answers. Reaching forqueryingwhen you wanted the elements is the mistake this page exists to prevent.- Compose optics once. Hoisting the composition out of a loop is the performance rule that matters; builder allocation is not.
filteredbelongs on the optic, not in the function. The predicate then travels with the path and cannot be forgotten at a call site.- Aggregate with a
Monoid.asFold().foldMap(...)beats collecting a list and reducing it.
Practice the fluent API in Tutorial 09: Fluent Optics API (7 exercises, ~10 minutes).
- Fluent API: the narrative version, with the four validation strategies
- Composing Optics: building the optics this page consumes
- Free Monad DSL: when the plan itself is the artefact
- Martin Fowler: Fluent Interface: the original description of the pattern
Previous: Fluent API Next: Integration and Recipes