Coming from Lombok, Streams and Switch

The Java you write today, the optic that replaces it, and when to keep the Java.

Most of what an optic does, you already do with a wither, a stream or a switch. This page sets each habit beside its path or optic, marks where the analogy breaks, and says when plain Java stays the better choice.


From Lombok and withers

A wither copies one record with one component replaced. A lens is the accessor and that wither as one value, and it composes, and Why a lens, when you have @With? makes the full case.

You write todayWith opticsNote
order.withStatus(PAID), one level deepOrderFocus.status().set(PAID, order), or the lens OrderLenses.status()a wither is enough for one shallow change; a lens earns its place once it is reused or composed
x.withA(f.apply(x.a())), a read and then a writepath.modify(f, x)one call reads and writes
A wither cascade three records deepOrderFocus.customer().email().value().modify(f, order)a chained hop needs navigators, generateNavigators = true; without them, each hop is .via(...)
withLo(5).withHi(10) on a record whose constructor checks bothLens.paired, which constructs oncetwo lens writes fail at the first, just as two withers do: Coupled Fields
toBuilder().sku(s).price(p).build(), several fields at onceEdits.combine(set(LineItemFocus.sku(), s), set(LineItemFocus.price(), p)).apply(line)combine builds a record per edit; for a constructor that checks fields together, accumulate onto a focus
@Builder, to create a recordkeep ita lens writes into a value you already have, so it replaces @With, never @Builder
A Lombok class with a builder or withers, rather than a recordan @ImportOptics spec with @ViaBuilder or @Witherthe generated lenses call your builder or your withers
Lombok's annotation processor in the buildLombok's processor first, then hkj-processorBuild-time impact says why

Here is the cascade, normalising the email on an order, with the withers Lombok's @With generates:

    // Lombok's @With: a wither per record, each nested inside the next, and the path read twice
    Order normalised =
        order.withCustomer(
            order
                .customer()
                .withEmail(
                    new EmailAddress(
                        order.customer().email().value().strip().toLowerCase(Locale.ROOT))));

And the path that replaces it:

    // One generated path, three records deep: it reads the email and rebuilds all three records
    Order normalised =
        OrderFocus.customer()
            .email()
            .value()
            .modify(email -> email.strip().toLowerCase(Locale.ROOT), order);

For an address sent as Ada@Example.COM, with spaces round it, both give ada@example.com. The path rebuilds the order, the customer and the email address, and the order's lines are the same list as before.


From streams

A stream over a list field reads the elements, and a wither puts the new list back. A traversal does both, at any depth, and a fold is the read-only half.

You write todayWith opticsNote
stream().map(f).toList() over a list field, put back with a witherOrderFocus.lines().via(LineItemFocus.price()).modifyAll(f, order)the path does the rebuild, however deep the list
stream().filter(p).map(f).toList().filter(p) on the path, then modifyAll(f, order)the stream drops what p rejects; the filtered path keeps it, unchanged
stream().map(f).toList(), to read the valuespath.getAll(order)
reduce, anyMatch, countfoldMap(monoid, f, order), exists(p, order) and count(order) on the pathonly reads; exists visits every element first, as What reads cost explains
Collectors.toMap over a map's entries, to change every valueCatalogueFocus.prices().each(EachInstances.mapValuesEach()).modifyAll(f, catalogue)toMap promises no map type and no order; the path keeps the source's iteration order
limit(n) or skip(n), then map(f)ListTraversals.taking(n) or dropping(n), reached from the list's lensthe stream drops the rest; the limited traversal keeps it, unchanged
A loop that checks every element and collects the failuresOpticOps.modifyAllValidated(order, path.toTraversal(), check)every bad value reported at once: Updates That Can Fail

Discounting the bulk lines, those of four or more, shows the difference that matters. A stream's filter would drop the lamp from the order, so the careful version tests inside map:

    // filter would drop the lamp from the order, so the test moves inside map
    Order discounted =
        order.withLines(
            order.lines().stream()
                .map(
                    line ->
                        line.quantity() >= 4
                            ? line.withPrice(line.price().multiply(new BigDecimal("0.9")))
                            : line)
                .toList());

A path's filter leaves the lamp in place, so the condition can stay a filter:

    // A filtered path: the lines it leaves out stay in the order, unchanged
    Order discounted =
        OrderFocus.lines()
            .filter(line -> line.quantity() >= 4)
            .via(LineItemFocus.price())
            .modifyAll(price -> price.multiply(new BigDecimal("0.9")), order);

For Ada's order of a £40.00 lamp and four £2.50 bulbs, both keep the lamp at 40.00 and price the bulbs at 2.250.


From switch and instanceof

A prism is an instanceof pattern and the variant's constructor in one value. An affine is an accessor that returns an Optional, with a copy that writes the value back.

You write todayWith opticsNote
if (state instanceof Returned returned), then returned.reason()ConsignmentStatePrisms.returned().getOptional(state), or matches(state) for the bare test@GeneratePrisms on the sealed interface writes one prism per variant
new Returned(reason), the variant's constructorreturned().build(new Returned(reason)), the variant as a ConsignmentStatefor a sealed variant, build only widens the type; Prisms.some().build(x) wraps, as Optional.of(x) does
A sealed switch that changes one case and passes the rest throughConsignmentFocus.state().via(returned()).via(ReturnedFocus.reason()).modify(f, consignment)the switch stops compiling when a variant is added; the prism passes the new one through
A switch that moves the state to another variant, Pending to DispatchedConsignmentFocus.state().via(pending()).matches(consignment), then ConsignmentFocus.state().set(new Dispatched(at), consignment)modify through a prism keeps the variant, so a move is a check and a write, as Send goods out shows
profile.altEmail().map(EmailAddress::value), and a copy to write it backCustomerProfileFocus.altEmail().via(EmailAddressFocus.value())reads as the chain does, and writes too; modify leaves an empty Optional alone

A sealed switch that tidies a returned consignment's reason, and passes every other state through:

    // A sealed switch: change one case, and pass the others through
    Consignment tidied =
        switch (consignment.state()) {
          case Returned returned -> consignment.withState(new Returned(returned.reason().strip()));
          case Pending _, Dispatched _ -> consignment;
        };

The same change through the prism:

    // The prism picks the Returned case, and any other state passes through
    Consignment tidied =
        ConsignmentFocus.state()
            .via(ConsignmentStatePrisms.returned())
            .via(ReturnedFocus.reason())
            .modify(String::strip, consignment);

A consignment returned with the reason damaged, padded with spaces, comes back with damaged from both. A pending one comes back from both as the very object it was.


The three that do not carry over

  1. A path never creates a record. It writes into one you already hold, so @Builder and your constructors stay.
  2. A filtered path keeps what it skips. A stream's filter leaves the rejected elements out of the result. A path's filter leaves them in the structure, unchanged, and only getAll and the other reads leave them out.
  3. A prism is not exhaustive. A sealed switch fails to compile when a variant is added, and a prism passes the new variant through. Keep the switch where every case needs an answer.

When plain Java wins

Plain Java wins here

  • One shallow change needs no optic. order.withStatus(PAID) says everything, and a path adds nothing until it is reused or composed.
  • A search that stops early is a loop. A path's reads visit every element (What reads cost), so a search that returns at the first match is a loop, or a stream's anyMatch.
  • A change of shape is a stream's job. A traversal keeps the collection and its element type, so grouping, flattening or mapping to another type stays a stream.

Migrating one method

  1. Add @GenerateFocus(generateNavigators = true) beside @With on the records the method changes. The two sit side by side.
  2. Replace the deepest cascade first, and assert that the old and new versions agree on a few fixtures.
  3. Once a second method uses a path, name it as a static final constant, as Caching optics suggests.
  4. Keep @With for one-level changes, and @Builder for creating records.

The code on this page is FromJavaBook.java and its FromJavaBookTest.java: the page includes the first, and the test holds the before-and-after pairs and the rows on streams and switch.


Where next


Previous: Composition Rules Next: Coming from Monocle or Haskell lens