When Lenses Assume Too Much

Update two fields a constructor checks together, such as a range's bounds, in one step.

Illustration for coupled fields and atomic updates

"A bug is never just a mistake. It represents something bigger. An error of thinking that makes you who you are."

– Elliot Alderson, Mr. Robot

When a lens update throws an exception, it is not the lens that is broken; it is our assumption about field independence. What looks like a bug often reveals a deeper truth about the relationship between fields in our data structures.

Consider a familiar scenario: you have a record with validation in its constructor. You have written (or generated) lenses for each field. Everything works perfectly, until you need to update two fields together. Suddenly, valid transformations become impossible.

The hidden culprit? Standard lens composition assumes fields are independent, that you can update lo without caring about hi. But some fields are coupled by invariants. They do not just coexist; they constrain each other. Lenses, in their elegant simplicity, do not know this.

What You'll Learn

  • Predict when two lens writes in a row fail on a record whose constructor checks two fields together
  • Update coupled fields in one step with Lens.paired, keeping the record's other components with the Function3 form
  • Update three to nine coupled fields the same way with CoupledLenses.coupled3 to coupled9
  • Decide when a field needs a paired lens, a domain method, or no lens at all

See Example Code


The Independence Assumption

When we compose lenses with andThen, we are drilling vertically through nested structures:

flowchart TD
    accTitle: Composing lenses vertically and horizontally
    accDescr: Vertically, andThen joins a Lens from S to A with a Lens from A to B, giving a Lens from S to B that reaches one nested field. Horizontally, paired joins a Lens from S to A and a Lens from S to B, giving a Lens from S to a Pair of A and B that reaches two sibling fields.
    %% declared in reverse: the layout places the last subgraph on the left
    subgraph horiz["Horizontal: paired, two fields at once"]
        direction TB
        H1(["Lens&lt;S, A&gt;"]) --> H3(["Lens&lt;S, Pair&lt;A, B&gt;&gt;<br/>two sibling fields"])
        H2(["Lens&lt;S, B&gt;"]) --> H3
    end
    subgraph vert["Vertical: andThen, drilling deeper"]
        direction TB
        V1(["Lens&lt;S, A&gt;"]) -->|"andThen"| V2(["Lens&lt;A, B&gt;"]) --> V3(["Lens&lt;S, B&gt;<br/>one nested field"])
    end

    classDef rw fill:#a6d189,stroke:#40a02b,color:#232634
    class V1,V2,V3,H1,H2,H3 rw

In words: andThen chains two lenses to reach one nested field, and paired puts two lenses side by side to reach two sibling fields at once.

Vertical composition (andThen) assumes that once you have focused on a field, you can update it independently. This works beautifully for nested structures like Employee → Company → Address → Street.

But what about sibling fields at the same level that share an invariant?


The Problem: Invariant Violation

Consider a simple bounded range:

record Range(int lo, int hi) {
    Range {
        if (lo > hi) {
            throw new IllegalArgumentException(
                "lo (" + lo + ") must be <= hi (" + hi + ")");
        }
    }
}

We can create lenses for each field:

Lens<Range, Integer> loLens =
    Lens.of(Range::lo, (r, lo) -> new Range(lo, r.hi()));

Lens<Range, Integer> hiLens =
    Lens.of(Range::hi, (r, hi) -> new Range(r.lo(), hi));

Now let us try to shift the range up by 10:

    Range range = new Range(1, 2);

    // Goal: Range(1, 2) → Range(11, 12)

    // Attempt 1: update lo first, which asks for Range(11, 2)
    Range step1 = loLens.set(11, range);
    // THROWS: "lo (11) must be <= hi (2)"

The update failed because the intermediate state Range(11, 2) violates the invariant.

What if we update hi first?

    Range step1 = hiLens.set(12, range); // Range(1, 12) - OK!
    Range step2 = loLens.set(11, step1); // Range(11, 12) - OK!

That works! But now try shifting down by 10 from Range(10, 11):

    Range narrow = new Range(10, 11);

    // If we update hi first: Range(10, 1) - THROWS!
    // If we update lo first: Range(0, 11) → Range(0, 1) - OK
    Range shiftedDown = hiLens.set(1, loLens.set(0, narrow));

The "correct" order depends on the direction of change!


Why This Happens

flowchart TD
    accTitle: Sequential and paired updates of a Range
    accDescr: Sequentially, loLens.set(11) turns Range(1, 2) into Range(11, 2), which breaks the invariant because 11 is greater than 2. Paired, boundsLens.get reads Range(1, 2) as Pair(1, 2), the transform gives Pair(11, 12), and Range::new builds Range(11, 12) with the invariant held.
    %% declared in reverse: the layout places the last subgraph on the left
    subgraph pair["Paired: both bounds together"]
        direction TB
        P1["Range(1, 2)"] -->|"boundsLens.get"| P2["Pair(1, 2)"]
        P2 -->|"transform"| P3["Pair(11, 12)"]
        P3 -->|"Range::new"| P4["Range(11, 12)<br/>invariant held"]
    end
    subgraph seq["Sequential: one field at a time"]
        direction TB
        S1["Range(1, 2)"] -->|"loLens.set(11)"| S2["Range(11, 2)"] --> S3["invariant broken:<br/>11 &gt; 2"]
    end

    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef out fill:#a6d189,stroke:#40a02b,color:#232634
    classDef bad fill:#e78284,stroke:#d20f39,color:#232634
    class S1,S2,P1,P2,P3 tier
    class P4 out
    class S3 bad

The goal is to shift both bounds by ten, from Range(1, 2) to Range(11, 12), under the invariant lo <= hi.

Sequential lens updates create intermediate states. When fields are coupled by an invariant, these intermediate states can be invalid, even when both the starting and ending states are perfectly valid.


The Solution: Paired Lenses

"They're all tied in together."

– Sergeant Pinback, Dark Star

This boils down to: if fields are coupled, update them together. Lens.paired combines two lenses into one that focuses on both values as a Pair:

Lens<Range, Pair<Integer, Integer>> boundsLens =
    Lens.paired(loLens, hiLens, Range::new);

Now we can shift safely:

    Range range = new Range(1, 2);

    // Shift up by 10 - both values updated atomically
    Range shifted = boundsLens.modify(p -> Pair.of(p.first() + 10, p.second() + 10), range);
    // Result: Range(11, 12)

The transformation happens in a single step:

  1. Extract both values: Pair(1, 2)
  2. Transform: Pair(11, 12)
  3. Reconstruct via Range::new: Range(11, 12)

No intermediate state. No invariant violation. Order independence.


API Reference

Lens.paired with BiFunction

When your record has only the coupled fields:

static <S, A, B> Lens<S, Pair<A, B>> paired(
    Lens<S, A> first,
    Lens<S, B> second,
    BiFunction<A, B, S> constructor
)

Example:

Lens<Range, Pair<Integer, Integer>> boundsLens =
    Lens.paired(loLens, hiLens, Range::new);

Lens.paired with Function3

When your record has additional fields that must be preserved:

static <S, A, B> Lens<S, Pair<A, B>> paired(
    Lens<S, A> first,
    Lens<S, B> second,
    Function3<S, A, B, S> reconstructor
)

Example:

record QuantityLimit(String id, int min, int max, String note) {
    QuantityLimit {
        if (min > max) throw new IllegalArgumentException("min > max");
    }
}

Lens<QuantityLimit, Pair<Integer, Integer>> limitsLens = Lens.paired(
    minLens,
    maxLens,
    (limit, newMin, newMax) -> new QuantityLimit(limit.id(), newMin, newMax, limit.note())
);

Choosing the Right Approach

ScenarioRecommended Approach
Independent fieldsUse individual lenses, or fold several into one operation with Edits
Fields with shared invariantUse Lens.paired
Sparse or validated edits to fields a constructor checks togetherUse Edits.accumulate(focus, …), with a Lens.paired as the focus if you have one
Computed/derived fieldsDon't expose a lens for the computed field
Cross-structure invariantsUse domain methods, not lenses

When NOT to Use Paired Lenses

Computed fields: If field B is always computed from field A, do not create a lens for B at all:

// Data is the source of truth; checksum is derived
Lens<Packet, byte[]> dataLens = Lens.of(
    Packet::data,
    (p, newData) -> new Packet(newData, computeChecksum(newData))
);
// No checksumLens - it's always recomputed

Cross-structure invariants: When invariants span parent and child objects, use domain methods. A Quotation, unlike the chapter's Order, stores the total its lines add up to:

// Don't use lenses - use domain operations
Quotation updated = quotation.withLine(
    sku, line -> new LineItem(line.sku(), line.quantity(), newPrice));
// The withLine method recalculates the quotation's total internally

Practical Examples

Bounded Ranges

record Range(int lo, int hi) {
    Range { if (lo > hi) throw new IllegalArgumentException(); }
}

Lens<Range, Pair<Integer, Integer>> boundsLens =
    Lens.paired(loLens, hiLens, Range::new);

// Shift
Range shifted = boundsLens.modify(
    p -> Pair.of(p.first() + 10, p.second() + 10),
    range
);

// Scale
Range scaled = boundsLens.modify(
    p -> Pair.of(p.first() * 2, p.second() * 2),
    range
);

// Widen symmetrically
Range widened = boundsLens.modify(
    p -> Pair.of(p.first() - 5, p.second() + 5),
    range
);

Rectangles with Constraints

record Viewport(Point topLeft, Point bottomRight) {
    Viewport {
        if (topLeft.x() >= bottomRight.x() || topLeft.y() >= bottomRight.y()) {
            throw new IllegalArgumentException("Invalid viewport bounds");
        }
    }
}

Lens<Viewport, Pair<Point, Point>> cornersLens =
    Lens.paired(topLeftLens, bottomRightLens, Viewport::new);

// Move the entire viewport
Viewport moved = cornersLens.modify(
    p -> Pair.of(
        p.first().translate(dx, dy),
        p.second().translate(dx, dy)
    ),
    viewport
);

Configuration with Port Ranges

record ServerPorts(String host, int minPort, int maxPort) {
    ServerPorts {
        if (minPort > maxPort || minPort < 1024 || maxPort > 65535) {
            throw new IllegalArgumentException("Invalid port range");
        }
    }
}

Lens<ServerPorts, Pair<Integer, Integer>> portsLens = Lens.paired(
    minPortLens,
    maxPortLens,
    (ports, min, max) -> new ServerPorts(ports.host(), min, max)
);

// Shift port range up by 1000
ServerPorts updated = portsLens.modify(
    p -> Pair.of(p.first() + 1000, p.second() + 1000),
    ports
);

Composition with Paired Lenses

A paired lens is just a normal Lens<S, Pair<A, B>>, so it composes naturally:

// A Deployment holds ServerPorts, whose port range is coupled
Lens<Deployment, Pair<Integer, Integer>> deploymentPorts =
    deploymentServerLens.andThen(serverPortsLens);

// Shift both ports atomically through the nested structure
Deployment updated = deploymentPorts.modify(
    p -> Pair.of(p.first() + 1000, p.second() + 1000),
    deployment
);

Anti-Pattern: Unpacking a Paired Lens

Avoid composing a paired lens with a lens that extracts a single element:

// DON'T DO THIS - defeats the purpose of pairing
Lens<Pair<Integer, Integer>, Integer> firstLens =
    Lens.of(Pair::first, (p, a) -> Pair.of(a, p.second()));
Lens<Range, Integer> justLo = boundsLens.andThen(firstLens);
// You're back to the original problem!

If you need single-field access, use the original individual lens directly.


Three or More Coupled Fields

Lens.paired is the binary case. For three to nine coupled fields, reach for the arity ladder in CoupledLenses (in org.higherkindedj.optics.util), generated by hkj-processor:

import org.higherkindedj.optics.annotations.GenerateLenses;
import org.higherkindedj.optics.util.CoupledLenses;

@GenerateLenses
record Triple(int lo, int mid, int hi) {
    Triple {
        if (!(lo <= mid && mid <= hi)) {
            throw new IllegalArgumentException("lo <= mid <= hi");
        }
    }
}

Lens<Triple, Integer> loLens  = Lens.of(Triple::lo,  (t, lo)  -> new Triple(lo, t.mid(), t.hi()));
Lens<Triple, Integer> midLens = Lens.of(Triple::mid, (t, mid) -> new Triple(t.lo(), mid, t.hi()));
Lens<Triple, Integer> hiLens  = Lens.of(Triple::hi,  (t, hi)  -> new Triple(t.lo(), t.mid(), hi));

// Same shape as Lens.paired, just one more lens and a Function3 constructor reference.
Lens<Triple, Tuple3<Integer, Integer, Integer>> bounds =
    CoupledLenses.coupled3(loLens, midLens, hiLens, Triple::new);

Triple shifted = bounds.modify(
    t -> new Tuple3<>(t._1() + 10, t._2() + 10, t._3() + 10),
    new Triple(1, 5, 10));
// The constructor only ever sees the new triple, never a half-shifted one.

The ladder runs coupled3 through coupled9 and each method has two overloads, mirroring Lens.paired exactly:

FormReconstructor signatureUse when
Preserving(S, A, B, C, ...) -> Sthe source has other fields the rebuild needs to keep
Simple(A, B, C, ...) -> S (constructor reference)the focused fields fully determine the source

Both records here carry @GenerateLenses: the Triple already shown, and a Trade(String currency, BigDecimal amount, int precision) whose withMoney method sets all three.

// Preserving form: receives the original source so other fields can be carried over.
Lens<Trade, Tuple3<String, BigDecimal, Integer>> money =
    CoupledLenses.coupled3(
        TradeLenses.currency(), TradeLenses.amount(), TradeLenses.precision(),
        (trade, ccy, amt, prec) -> trade.withMoney(ccy, amt, prec));

// Simple form: constructor reference when nothing else needs preserving.
Lens<Triple, Tuple3<Integer, Integer, Integer>> bounds =
    CoupledLenses.coupled3(TripleLenses.lo(), TripleLenses.mid(), TripleLenses.hi(), Triple::new);

Why CoupledLenses, not Lens.coupled3?

Lens.paired lives on the Lens interface in hkj-api. Generated code cannot add static methods to an existing API interface, so the arity ladder lives in hkj-core's org.higherkindedj.optics.util package as a separate utility class. One discoverability cost: you import CoupledLenses to use the ladder. Everything else is identical to Lens.paired, including the two-overload pattern, the atomic-reconstruction semantics, and the lens laws.

Past nine fields?

The ladder caps at coupled9 because cross-field invariants past five fields are already rare and past nine vanishing. If you genuinely need a higher arity (rather than splitting the record), open a GitHub feature request and the generator cap can be raised.

Lens Laws

Paired lenses satisfy the standard lens laws when the two lenses focus on different components and the constructor accepts the values you set:

  • GetPut: set(get(s), s) == s
  • PutGet: get(set(a, s)) == a
  • PutPut: set(a2, set(a1, s)) == set(a2, s)

These are verified by property-based tests in LensPairedLawsPropertyTest.java.


Key Takeaways

  • Standard lenses assume field independence: updating one field should not affect another
  • Coupled fields violate that assumption: an invariant makes two fields constrain each other
  • Sequential updates pass through invalid intermediate states, even when the start and the end are both valid
  • Lens.paired updates the group atomically: no intermediate state exists for the constructor to reject
  • CoupledLenses.coupled3 to coupled9 is the same shape for more fields, generated into org.higherkindedj.optics.util
  • Order independence follows: a paired lens does not care which direction you are transforming

Hands-On Learning

Practise both the binary form and the arity ladder in Tutorial 23: N-ary Coupled Lenses (3 exercises).

See Also

Further Reading

Chris Penner: Virtual Record Fields Using Lenses: introduces "virtual fields" as computed properties accessed through lenses. Penner demonstrates how hiding data constructors and exporting only lenses creates a stable public interface that absorbs internal refactoring. His treatment of data invariants is relevant here: where we use Lens.paired to enforce invariants during updates, Penner uses lenses to hide representation details and maintain invariants transparently. He also candidly notes that breaking lens laws is "usually perfectly fine" for pragmatism, echoing our observation that real-world records often have constraints that do not fit the idealised lens model.

Gunnar Morling: Enforcing Java Record Invariants With Bean Validation: tackles record invariants from a different angle, using Bean Validation annotations to enforce constraints automatically at construction time. The article explicitly discusses multi-field invariants like "end must be greater than begin", precisely the kind of coupled constraint that breaks sequential lens updates. Morling's approach guarantees the invariant holds but does not help you transform a valid object when both fields must change together. This is where Lens.paired complements Bean Validation: validation solves the construction problem; paired lenses solve the transformation problem. In a robust system, you would use both.


Previous: Lenses Next: Prisms