Functional Java is Great, But Nested Records Are a Nightmare

I spent three hours last Tuesday staring at a massive block of boilerplate code that was supposedly doing me a favor. We migrated a massive chunk of our billing system to Java 25 last month, heavily leaning into Records to enforce immutability across our domain model.

Immutability is fantastic. It stops thread-safety bugs dead in their tracks. But if you’ve actually tried building complex applications this way, you’ve probably hit the exact same wall I did.

Updating a flat record is easy. You just copy it. But updating a nested record? That’s a completely different story. It makes you want to throw your laptop out the window.

The Immutability Gap

Let’s say you have a standard e-commerce domain. Nothing crazy. Just an Order, which has a Customer, which has an Address.

public record Address(String street, String city, String zip) {}
public record Customer(String id, String name, Address address) {}
public record Order(String orderId, Customer customer, double total) {}

Now, imagine your user realizes they made a typo in their street name. In the bad old days of mutable beans, you’d just do order.getCustomer().getAddress().setStreet("New Street"). Done.

But these are Records. They are frozen. To change the street, you have to reconstruct the entire object tree from the bottom up. Look at this absolute mess:

Order updatedOrder = new Order(
    oldOrder.orderId(),
    new Customer(
        oldOrder.customer().id(),
        oldOrder.customer().name(),
        new Address(
            "123 Fixed Street", // The actual change
            oldOrder.customer().address().city(),
            oldOrder.customer().address().zip()
        )
    ),
    oldOrder.total()
);

Gross. If you add a field to Customer next week, this code breaks. You have to go update every single place you did this manual reconstruction.

frustrated programmer at laptop - Stressed overworked developer programming html code on laptop and ...
frustrated programmer at laptop – Stressed overworked developer programming html code on laptop and …

I almost caved and went back to mutable POJOs. But a buddy of mine who writes a lot of Scala pointed me toward a functional programming concept called Optics—specifically, Lenses.

Writing a Simple Lens in Java

A Lens is basically a functional getter and setter combined. It knows how to extract a piece of data from a complex object, and it knows how to create a new version of that complex object with updated data.

You don’t need a massive third-party library to do this. You can write a basic Lens interface yourself. Here’s exactly what I dropped into our codebase:

import java.util.function.BiFunction;
import java.util.function.Function;

public interface Lens<S, A> {
    A get(S source);
    S set(S source, A newValue);

    // The magic happens here: composing lenses together
    default <B> Lens<S, B> compose(Lens<A, B> other) {
        return new Lens<S, B>() {
            @Override
            public B get(S source) {
                return other.get(Lens.this.get(source));
            }

            @Override
            public S set(S source, B newValue) {
                A innerUpdated = other.set(Lens.this.get(source), newValue);
                return Lens.this.set(source, innerUpdated);
            }
        };
    }
}

The S is the Source object (like our Order), and A is the focus (like the Address).

Now we define our lenses. Usually, I keep these as static fields right inside the records themselves, but I’ll list them out here so you can see the wiring:

// Lens to go from Order to Customer
Lens<Order, Customer> ORDER_CUSTOMER = new Lens<>() {
    public Customer get(Order s) { return s.customer(); }
    public Order set(Order s, Customer c) { 
        return new Order(s.orderId(), c, s.total()); 
    }
};

// Lens to go from Customer to Address
Lens<Customer, Address> CUSTOMER_ADDRESS = new Lens<>() {
    public Address get(Customer s) { return s.address(); }
    public Customer set(Customer s, Address a) { 
        return new Customer(s.id(), s.name(), a); 
    }
};

// Lens to go from Address to Street
Lens<Address, String> ADDRESS_STREET = new Lens<>() {
    public String get(Address s) { return s.street(); }
    public Address set(Address s, String str) { 
        return new Address(str, s.city(), s.zip()); 
    }
};

The Payoff: Streams and Composition

Setting up the lenses takes a few lines of code. I won’t lie about that. But once you have them, updating deeply nested structures becomes incredibly clean. You just compose the lenses together into a single path.

Let’s say we have a batch of orders, and we need to fix a spelling mistake in a street name across all of them using the Stream API. Watch this:

frustrated programmer at laptop - Frustrated programmer with glasses is sitting at a laptop and ...
frustrated programmer at laptop – Frustrated programmer with glasses is sitting at a laptop and …
// Compose a single lens that goes from Order straight to Street
Lens<Order, String> orderStreetLens = ORDER_CUSTOMER
    .compose(CUSTOMER_ADDRESS)
    .compose(ADDRESS_STREET);

List<Order> updatedOrders = orders.stream()
    .map(order -> {
        String currentStreet = orderStreetLens.get(order);
        if (currentStreet.contains("Oak Stret")) {
            return orderStreetLens.set(order, currentStreet.replace("Stret", "Street"));
        }
        return order;
    })
    .toList();

No manual object reconstruction. No worrying about adding new fields to intermediate records. The lens handles the cloning logic at every step.

The Ugly Truth About GC Overhead

I need to be upfront about something. Creating a bunch of intermediate objects isn’t free.

Before merging this pattern into our main branch, I ran a benchmark on my M3 MacBook Pro using JMH. I pushed a 50K row dataset of complex orders through the lens mutator, and then ran the exact same logic using dirty, old-school mutable objects.

The mutable approach took about 0.4 seconds. The functional lens approach? 1.2 seconds.

Every time you call set() on a composed lens, you are instantiating a new Address, a new Customer, and a new Order. The garbage collector has to clean all that up. If you are writing a high-frequency trading engine where microseconds matter, do not do this. Stick to mutable arrays or primitive buffers.

But for a standard Spring Boot 3.4 REST API? That 800ms difference spread across 50,000 records is entirely negligible. I’ll gladly trade a tiny bit of CPU time for code that doesn’t randomly mutate state across three different service layers.

What’s Next?

I actually don’t think we’ll be writing custom Lenses forever. If you look at the mailing lists, there’s been heavy discussion about adding C#-style with expressions to Java.

I’m betting that by Q3 2027, we’ll see native compiler support that lets us do something like order with { customer.address.street = "New Street" } directly in the language.

Until then, functional optics bridge the gap. They let you keep the safety of Records without losing your mind every time a user needs to update their profile. Try wiring up a simple Lens interface in your next project. It completely changed how I handle domain models.

Java Records Documentation Java Function Interfaces Documentation Java JMH Benchmarking Documentation