Making Lenses Practical in Java

Published: 2023-01-15

This is about Lenses, what they are, how you write them by hand, why writing them by hand sucks, how to stop writing them by hand, and how to have an awesome library do it for you. We're going to turn code like this:

pendingOrders.map(order -> 
        order.withApproval(order.getApproval().withConfirmation(
                order.getApproval().getConfirmation().withUpdatedOn(LocalDateTime.now())
        )) 
    )

into code like this:

pendingOrders.map(setApprovalConfirmationUpdatedOn(LocalDateTime.now()));

What's a lens? For the purposes of Java speak, we're going to call them chainable (or maybe fluent?) getters and setters[0]. They give us a way to update complex, potentially nested, immutable objects without any of the awful boilerplate that we usually have to endure.

They don't come for free, though. Actually, on their own, Lenses are pretty impractical if you're trying to type them out by hand. They require laying a lot of code scaffolding before you can start doing anything useful. It's the type of thing where you can end up writing hundreds of lines of code in an effort to save dozens. However, once they're in place, they enable expressing updates with a level of clarity that borders on magical.

Background: making updates without lenses

The main problem here is that making complex updates to immutable data classes is terrible. While Lombok has helped drag us out of the dark ages, updating our shiny immutable data classes is still done with primitive tools. The more complex the data, the less effective these tools become.

Java's hammer for immutable updates is the withFoo(...) pattern ("Withers" from here on out). These are just like the fluent builders and setters of old, except they return back a copy of the object, rather than mutating it in place.

// These are the data classes we'll use 
// throughout the article

@With
@Value
class PurchaseOrder {
    String number; 
    Approval approval; 
    Integer version; 
}
@With
@Value
class Approval {
    ApprovalStatus status;
    List<Comment> comments; 
    Confirmation confirmation;
}
@With
@Value
class Confirmation {
    UserAlias alias; 
    LocalDateTime updatedOn; 
}

The Withers are actually pretty great as long as your updates are simple and at the root level of your object.

order.withNumber("000A12").withVersion(1)  

However, as soon as you try to do any updates that involves the object's children things descend into chaos.

order.withApproval(order.getApproval().withConfirmation(
	order.getApproval().getConfirmation().withUpdatedOn(LocalDateTime.now())
));

What we're trying to accomplish (stamping the current time on the Confirmation) is completely overshadowed by the mechanics required to do it. We've lost the "what" in a sea of "how". The more complex the action, the worse this boilerplate becomes.

The problem is that Withers don't compose across types. They only know about the object they belong to, which is why we're forced to create pyramids of nested Withers whenever we need to update child objects.

Lenses make updates composable

Here's the same update using lenses.

approval.compose(confirmation).compose(updatedOn).set(order, LocalDateTime.now())

We're composing things by hand for sake of example, so this is still a bit boilerplate-y. We can do better by factoring out the manual composition:

set($approval, $confirmation, $updatedOn, LocalDateTime.now()).apply(order)

And then we can go even further – because composition with lenses is entirely and mindlessly mechanical, we don't need to do any of it by hand! Just like Lombok can automatically generate Withers that operate on the root object, we can have Deoplice generate Withers that operate across the entire hierarchy of the data type!

setApprovalConfirmationUpdatedOn(LocalDateTime.now()).apply(order) 

Further, you can generate these custom methods for every field in your class. Because lenses compose, you can turn even complex updates across multiple data hierarchies into crystal clear declarative statements.

// Try doing this with just nested `withX(...)` statements! 
addApprovalComments(Comment.of("lgtm!"))
	.andThen(setApprovalStatus(COMPLETED)) 
	.andThen(updateVersion(x -> x + 1))
	.apply(order)

Beyond just the reduction in boilerplate (which is a lot!), the main gain is readability. We're no longer burying the "what" in layers of "how". We get to live up in declarative land where we can just say "Add a new comment, change the status to completed, and bump the version."

How does it work?

Lenses: from the ground up

Lenses, at their most basic, are just an abstraction on top of getters and setters. This is the whole definition[0]:

interface Lens<A, B> {  
    B get(A a);
    A set(A a, B b)
}

This is just saying that for some object type A we must have a getter and setter for its field of some type B.

An implementation looks something like this:

@With
@Value
class Confirmation {
    LocalDateTime updatedOn; 
} 

// NEW! 
// This lens "focuses" on the updatedOn field of the Confirmation class
Lens<Confirmation, LocalDateTime> $updatedOn = new Lens<>() {  
    @Override  
    public LocalDateTime get(Confirmation conf) {  
        return conf.getUpdatedOn();
    }  
  
    @Override  
    public Confirmation set(Confirmation conf, LocalDateTime updatedOn) {
        return updatedOn.withUpdatedOn(updatedOn);     
    }  
};

In Optics parlance, this lens focuses on the updatedOn field inside of the Confirmation class.

On it's own, it's not very interesting. All we've done is wrap the getters and setters we already had on the object. The real power comes from the fact that we can define what it means for two lenses to compose

interface Lens<A, B> {
    B get(A a);
    A set(A a, B b);

	// NEW! 
	default <C> Lens<A, C> compose(Lens<B, C> inner) {  
		Lens<A, B> outer = this;  
		return new Lens<A, C>() {  
			@Override  
			public C get(A a) {  
				return inner.get(outer.get(a));  
			}  
	  
			@Override  
			public A set(A a, C c) {  
				// Check it out! We're abstracting away the mechanics 
				// of "how" we descend into inner child types so that we 
				// don't have to deal with it as consumers
				return outer.set(a, inner.set(outer.get(a), c));  
			}  
		};  
	}
}

Now we've got something that's subtly magical. We can compose lenses just like we can compose functions. If we've got a Lens from PurchaseOrder -> Approval and we compose it with a Lens from Approval -> Confirmation, we're gifted a new function that goes from PurchaseOrder -> Confirmation. This is massive, because now we don't have to nest ever deeper calls to withers when we want to change a piece of nested data -- the lens does it for us -- we can just compose a recipe that targets it, and then call set.

Here's an example of just that:

@With  // NEW! 
@Value
class Approval {
    Confirmation confirmation;
}
@With
@Value
class Confirmation {
    LocalDateTime updatedOn; 
} 


// NEW! This lens focuses on Approval's confirmation field
Lens<Approval, Confirmation> $confirmation = new Lens<>() {  
    @Override  
    public Confirmation get(Approval approval) {  
        return approval.getConfirmation();
    }  
  
    @Override  
    public Approval set(Approval approval, Confirmation conf) {
        return approval.withConfirmation(conf);     
    }  
};


Lens<Confirmation, LocalDateTime> $updatedOn = new Lens<>() {  
    @Override  
    public LocalDateTime get(Confirmation conf) {  
        return conf.getUpdatedOn();
    }  
  
    @Override  
    public Confirmation set(Confirmation conf, LocalDateTime updatedOn) {
        return updatedOn.withUpdatedOn(updatedOn);     
    }  
};

This compositional power takes us from nested withers:

approval.withConfirmation(approval.getConfirmation().withUpdatedOn(LocalDateTime.now()))

To composed lenses!

$confirmation.compose($updatedOn).set(approval, LocalDateTime.now())

Pretty sick! Now you see the real boilerplate reducing power of lenses! To save typing 20 characters, we only had to type an extra 723! That's a huge savi– wait a minute – no, no. right... This still sucks.

This code explosion is why writing lenses by hand is impractical. We could, of course, offload the creation to helper functions to hide the boilerplate of the interface implementation, but the root problem remains: typing this out for every field in every class would suuuuuck. God help you if you decide to rename one of those fields.

Which is why we don't do that. We offload that drudgery to the machines.

Automatically Generating Lenses in Java

Note: Deoplice is in alpha and is currently symbiotic with Lombok. It depends on its @With annotations in order to generate the Lenses which back its API implementation

Deoplice is a library[1] for automatically generating lens implementations. It works via annotations just like Lombok.

Given our same set of starting classes, we can generate not only lenses for every field, but also all of their possible compositions as a pre-baked API!

@With
@Value
@Updatable // NEW! 
class PurchaseOrder {
    String number; 
    Approval approval; 
    Integer version; 
}
@With
@Value
class Approval {
    ApprovalStatus status;
    List<Comment> comments; 
    Confirmation confirmation;
}
@With
@Value
class Confirmation {
    UserAlias alias; 
    LocalDateTime updatedOn; 
}

The generated lenses mirror your classes 1:1 but will have an added Lens suffix on the class name, and a $ prefix on the variable names (to avoid collisions). So, our PurchaseOrder class:

class PurchaseOrder {
    String number; 
    Approval approval; 
    Integer version; 
}

Would get a generated lens version like this:

class PurchaseOrderLens {
	Lens<PurchaseOrder, String> $number = ... 
	Lens<PurchaseOrder, Approval> $approval = ... 
	Lens<PurchaseOrder, Integer> $version = ... 
}

And ditto for every other class referenced from the annotated one.

Since manually composing lenses is still obnoxious, Deoplice also ships with a suite of helper methods for building long chains of updates.

set($approval, $confirmation, $updatedOn, LocalDateTime.now())
	.andThen(update($approval, $version, x -> x + 1))
	.apply(order); 

But lenses – even when entirely generated – are still too verbose! The real way to use Deoplice is to move one level up the abstraction ladder and use the API it generates on top of the lenses!

With it, you can create declarative updates that read just like English prose.

addApprovalComments(Comment.of("lgtm!"))
	.andThen(setApprovalStatus(COMPLETED)) 
    .andThen(updateVersion(x -> x + 1))
    .apply(order)

Further, it goes beyond getters and setters and provides immutable facades for interacting with collection types on your objects! Trying to deal with Java's mutable collections when they're used on what is supposed to be an immutable object is usually a recipe for pain. Almost invariably, to do something as simple as adding an item to a list, you have to pull the list out of the POJO, make a copy, do the transform, and then with (or Lens.set) it back into place.

List<Comment> original = order.getApproval().getComments()
List<Comment> updated = Stream.concat(original, Stream.of(Comment.of("lgtm!")).collect(Collectors.toList());
return order.withApproval(order.getApproval().withComments(updated));

More boilerplate that hides what we're trying to actually accomplish! Instead, Deoplice's API does all of this copy-on-write for you behind the scenes. You get to stay up in declarative land and leave all the plumbing to the generated code.

Here's how that exact same action looks with Deoplice's generated API:

addApprovalComment(Comment.of("lgtm!")).apply(order); 

This is what updating immutable data classes can look like with modern tooling.

Checkout Deoplice and let me know what you think!

Footnotes

  • [0] The Optics nerds would call such a definition worthless and wrong, but that's OK.
  • [1] Written by a super cool dude.