Here's a POJO. Here are the getters and setters. Off you go — build a system.

And to be fair, that approach works… at the beginning.

The problems show up later. The codebase grows. More people join the team. And suddenly that "harmless little setter" is responsible for a bug that only appears in production — on a Sunday — during a leap year.

This blog is about two things:

  • Why mutability causes real pain in Java systems, even when everything seems fine today.
  • How functional optics (lenses and prisms), combined with modern Java features like records and sealed types, give you a practical way out

You don't need to be a functional programming purist. You just need to be tired of fragile code.

1. What Is Mutability?

Let's look at a very basic example.

A mutable object is one whose fields can change after it's created. Classic Java beans:

public class Employee {
    private String id;
    private String name;
    private String street;

    // getters and setters
    public String getId() { return id; }
    public void setId(String id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getStreet() { return street; }
    public void setStreet(String street) { this.street = street; }
}

You create it once, then you can do this forever:

employee.setStreet("New Street");

On the other hand, an immutable object never changes once created. In modern Java that usually means a record:

public record Employee(String id, String name, String street) {}

If you want a different street, you create a new Employee:

var updated = new Employee(emp.id(), emp.name(), "New Street");

The original emp stays exactly as it was.

So far, mutability sounds more convenient, right?

  • No need to recreate objects.
  • No extra garbage.
  • Easy to "just update the field."

So why do people keep go on about immutability being better?

2. Why Mutability Feels Fine (Until It Doesn't)

Mutability problems are sneaky because they rarely explode in your face in small systems. They show up gradually as:

  • Weird bugs that are hard to reproduce.
  • Confusing side effects ("who changed this object?").
  • Code you're scared to touch.

Let's walk through three very concrete issues.

2.1. Hidden Side Effects

Imagine this method:

void updateStreet(Employee employee, String newStreet) {
    employee.setStreet(newStreet);
}

Looks harmless. But what if the caller did this:

Employee e = employeeRepository.findById("123");
cache.put("employee:123", e);

updateStreet(e, "New Street");

Now both the original variable e and the cached Employee inside cache have changed. You didn't touch the cache, but its contents changed anyway—because they point to the same mutable object.

This is the core problem:

With mutability, who owns the object and who's allowed to change it become unclear.

You can work around this with defensive copies and careful conventions, but that's on you, every time, forever.

2.2. Concurrency Pain

Even if you're not deep into multithreading, you've probably seen code like this:

public class SharedConfig {
    private String mode;

    public synchronized void setMode(String mode) { this.mode = mode; }
    public synchronized String getMode() { return mode; }
}

Multiple threads all reading and writing mode. You keep adding synchronized until the bugs go away—or performance tanks.

Immutable objects don't have this problem:

  • Once created, they never change.
  • You can safely share them across threads without locks.

You pay the cost in creating new objects, but you save on mental overhead and race-condition debugging.

2.3. Testing and Debugging Confusion

With mutable objects, a failing test often leaves you wondering:

  • What was the state before this method ran?
  • Did some other method mutate this object earlier?
  • Is this method itself changing too much stuff?

With immutable objects, each method takes some values in and returns some values out. You log or assert those values. That's it.

So if immutability is so great, why doesn't everyone use it?

Because of this one, very real problem.

3. The Real Cost of Immutability: Deep Updates Suck

Let's move to a slightly richer example.

public record Address(String street, String city, String postcode) {}

public record Employee(String id, String name, Address address) {}

public record Department(String name, Employee manager, List<Employee> staff) {}

Nice, clean, immutable. Until the product owner walks over and says:

"We need to update the manager's street when they move office."

No setters. No mutation. So what do you do?

3.1. Manual Deep Update (The Painful Way)

public static Department updateManagerStreet(Department dept, String newStreet) {
    // Step 1: old manager and address
    Employee oldManager = dept.manager();
    Address oldAddress = oldManager.address();

    // Step 2: new address
    Address newAddress = new Address(newStreet, oldAddress.city(), oldAddress.postcode());

    // Step 3: new manager
    Employee newManager = new Employee(oldManager.id(), oldManager.name(), newAddress);

    // Step 4: new department
    return new Department(dept.name(), newManager, dept.staff());
}

On a small example, this looks fine. But imagine:

  • More fields added to Employee later (email, role, permissions…).
  • Multiple places in your code doing similar updates.

Every new field means hunting down all these deep-copy methods and updating them.

The team usually starts with one manual copy method. Then another. Then they realize they need to copy in 5 different places. Soon they have a EmployeeCopyUtils class with 12 static methods, each slightly different, each slightly broken

This is where functional optics come in.

They don't magically solve immutability. Records already did that for you. What optics solve is the boilerplate and fragility of deep updates.

4. Lenses: Reusable, Composable Get/Set for Immutable Data

A lens is a tiny object that knows how to:

  • Get a field out of some structure S.
  • Return a new S with that field replaced.

Think of it as a reusable, type-safe "path" into your data.

4.1. A Minimal Lens Type

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

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

    default S modify(Function<A, A> f, S source) {
        return set(f.apply(get(source)), source);
    }

    // Compose two lenses: S→A and A→B gives S→B
    default <B> Lens<S, B> andThen(Lens<A, B> next) {
        return new Lens<>() {
            @Override
            public B get(S source) {
                return next.get(Lens.this.get(source));
            }

            @Override
            public S set(B value, S source) {
                A updatedInner = next.set(value, Lens.this.get(source));
                return Lens.this.set(updatedInner, source);
            }
        };
    }

    static <S, A> Lens<S, A> of(Function<S, A> getter,
                                BiFunction<S, A, S> setter) {
        return new Lens<>() {
            @Override public A get(S source) { return getter.apply(source); }
            @Override public S set(A value, S source) { return setter.apply(source, value); }
        };
    }
}

This is plain Java:

  • get/set do what you expect.
  • modify lets you transform the field.
  • andThen lets you compose paths.

4.2. Lenses for Our Records

Let's define lenses for Department, Employee, and Address.

public final class Lenses {
    // Department → manager
    public static final Lens<Department, Employee> departmentManager =
        Lens.of(Department::manager,
                (dept, newManager) -> new Department(dept.name(), newManager, dept.staff()));

    // Employee → address
    public static final Lens<Employee, Address> employeeAddress =
        Lens.of(Employee::address,
                (emp, newAddress) -> new Employee(emp.id(), emp.name(), newAddress));

    // Address → street
    public static final Lens<Address, String> addressStreet =
        Lens.of(Address::street,
                (addr, newStreet) -> new Address(newStreet, addr.city(), addr.postcode()));
}

4.3. Compose Once, Use Everywhere

Now we can build a path from Department to the manager's street:

public final class DepartmentOptics {

    public static final Lens<Department, String> managerStreet =
        Lenses.departmentManager
              .andThen(Lenses.employeeAddress)
              .andThen(Lenses.addressStreet);

    public static Department updateManagerStreet(Department dept, String newStreet) {
        return managerStreet.set(newStreet, dept);
    }
}

Notice the difference:

  • The business logic is a single line.
  • The complicated "how do I rebuild this?" logic lives in well-defined lenses.
  • If someone adds a field to Employee, you update your lens in one place instead of hunting through your whole codebase.

You can also transform the street instead of replacing it:

public static Department normalizeManagerStreet(Department dept) {
    return DepartmentOptics.managerStreet.modify(
        street -> street.trim().toUpperCase(),
        dept
    );
}

Same path, different behavior.

5. Prisms: Working with Sealed Interfaces Safely

Lenses work for records (product types). To work with sealed interfaces (sum types), we use a different optic: the prism.

Say you're modelling expressions:

public sealed interface Expr permits Literal, Variable, Binary {}

public record Literal(int value) implements Expr {}

public record Variable(String name) implements Expr {}

public record Binary(Expr left, String op, Expr right) implements Expr {}

Sometimes you want to say:

"If this Expr is a Literal, increment its value."

You can do that with instanceof, sure. But a prism turns that logic into a reusable object you can compose.

5.1. Minimal Prism

import java.util.Optional;
import java.util.function.Function;

public interface Prism<S, A> {
    Optional<A> getOptional(S source);
    S build(A focus);

    default S modify(Function<A, A> f, S source) {
        return getOptional(source)
            .map(a -> build(f.apply(a)))
            .orElse(source);
    }
}

5.2. Prism for Expr → Literal

public final class ExprPrisms {
    public static final Prism<Expr, Literal> literalPrism = new Prism<>() {
        @Override
        public Optional<Literal> getOptional(Expr source) {
            return (source instanceof Literal l) ? Optional.of(l) : Optional.empty();
        }

        @Override
        public Expr build(Literal focus) {
            return focus;
        }
    };
}

Now you can write:

public Expr incrementLiteral(Expr expr) {
    return ExprPrisms.literalPrism.modify(
        lit -> new Literal(lit.value() + 1),
        expr
    );
}

If expr is a Literal, it increments it. Otherwise, it returns the original expression unchanged. No casts leaking around. No repeated instanceof logic.

6. How Optics and Modern Java Fit Together

Functional optics aren't some alien concept bolted onto Java — they happen to fit really nicely with the language features you already have:

  • Records → perfect for lenses. The record components become lens targets.
  • Sealed interfaces → perfect for prisms. The allowed subtypes are exactly the cases.
  • Pattern matching for switch → great for one-off decisions; optics for reusable paths.

You can absolutely live without optics. Plenty of codebases do.

But if you:

  • Prefer immutable data (for clarity, thread-safety, and easier testing), and
  • Are tired of hand-rolling copy constructors and deep updates,

…optics are the missing piece that make your life easier.

7. When You Should Care (and When You Probably Don't Need To)

You should seriously consider optics if:

  • Your domain model is deeply nested and mostly immutable.
  • You find yourself writing the same deep-copy patterns multiple times.
  • You want to separate "how to navigate the structure" from "what business change should happen."

You can probably skip optics (for now) if:

  • Your objects are mostly flat and small.
  • Your system is simple enough that a few manual copies don't hurt.
  • Your team is still getting comfortable with records and sealed interfaces.

A good way to start is not to rewrite everything, but to:

  1. Pick one painful deep update.
  2. Extract a couple of lenses to handle it.
  3. Use them in one or two places.
  4. See how you feel about the readability and safety.

If you like it, you'll naturally reach for the pattern the next time you see nested immutable data.

8. Wrapping Up

Mutability isn't evil. It's just easy in the short term and expensive in the long term.

  • It hides where changes come from.
  • It makes concurrency harder.
  • It complicates testing and reasoning.

Immutability fixes a lot of that — but naive immutability dumps the pain into deep updates.

Functional optics — lenses for records, prisms for sealed interfaces — are the tools that take that pain away. They turn "rebuild a whole object graph by hand" into "call this reusable path and be done with it."

You don't have to become a functional programming purist. You don't have to adopt a giant library. You can start with the tiny Lens and Prism interfaces from this post and see if they make your day-to-day work a little less dreadful.

If you've ever looked at a block of copy-constructor code and thought, "There has to be a better way," you're not crazy.

There is a better way.

It's just a small matter of how you choose to look at your data.