Which Java Construct Should You Use? Let Change Drivers Decide

java dev.to

A change driver is anything that, when it changes, forces an element of a system to change.
The Independent Variation Principle (IVP) constrains how modules and their elements relate to these drivers: elements sharing a module must have the same driver assignment, and elements with different driver assignments must live in different modules.

Applied to Java constructs, the lens separates essential coupling — the drivers the situation actually requires — from accidental coupling — the drivers the chosen construct drags in for reasons that have nothing to do with the situation.
Picking the right construct is picking the one whose realization carries only the essential drivers.
Throughout the article, "before" constructs carry accidental coupling (typically an implicit outer-instance reference, a synthetic class file, or a mutation channel the situation does not need); "after" constructs remove the accidental artifacts and keep only what the situation requires.

Non-static inner class

A non-static inner class carries an implicit OuterClass.this reference, generated by the compiler whether or not the inner class body ever uses it.
The structural consequence is that the inner class's driver set automatically includes all of the outer class's change drivers.
If OuterClass has three independent reasons to change, the inner class inherits all three.

Non-static inner classes are often the right choice.
When the inner class's behavior must observe live changes to the outer instance's state — not just read its fields once, but see every write that happens between construction and use — the driver import is warranted: any change to how the outer tracks that state forces a corresponding change in the inner class.
The canonical case is a fail-fast iterator over a mutable collection:

// Warranted: RingBufferIterator must observe structural modification of the
// buffer during iteration — fail-fast, single-threaded. modCount is
// outer-instance state that the iterator reads on every step; the coupling
// to the outer instance is structural, not convenience.
class RingBuffer<T> {
    private final Object[] elements;
    private int head;
    private int size;
    private int modCount;   // bumped before every structural change

    public Iterator<T> iterator() {
        return new RingBufferIterator();
    }

    public void add(T value) {
        modCount++;          // bump first, then mutate
        // ... store into elements[(head + size) % elements.length]
        size++;
    }

    class RingBufferIterator implements Iterator<T> {
        private int cursor = 0;
        private final int expectedModCount = modCount;

        @Override
        public boolean hasNext() {
            return cursor < size;
        }

        @Override
        @SuppressWarnings("unchecked")
        public T next() {
            if (modCount != expectedModCount) {
                throw new ConcurrentModificationException();
            }
            if (cursor >= size) {
                throw new NoSuchElementException();
            }
            return (T) elements[(head + cursor++) % elements.length];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The modCount check is what makes the outer coupling load-bearing: a static iterator that received elements, head, and size as constructor parameters would see a frozen snapshot and could not detect mutation.
Because the iterator must observe modCount on every next(), it must hold a live reference to the buffer — that is exactly what a non-static inner class provides.

Essential coupling: live access to the buffer's modCount and backing array.
Accidental coupling introduced by the non-static inner class: none — the compiler-synthesized this$0 reference is exactly what the fail-fast check needs. A static nested class would achieve the same thing by taking an explicit RingBuffer<T> constructor parameter; the reference would be user-declared instead of compiler-synthesized, but otherwise identical. For this situation the non-static syntax is the right choice precisely because the outer-instance reference it imports is essential.

The case to watch for is the subset where the inner class body never references OuterClass.this at all — there the compiler-generated outer reference is present but the body does not exercise it, and promoting the class to static (or extracting it entirely) removes the reference at no other cost.

// Unwarranted: Address is a plain data bundle whose definition depends
// only on what an address is — street, city, postal code. Address does
// not reference any Customer field, yet the Java compiler adds an implicit
// Customer.this reference to every Address instance. Java serialization
// of a non-static inner class drags the enclosing Customer along; tests
// that want an Address must construct a Customer first; and every
// Address holds a reference preventing its enclosing Customer from
// being collected.
class Customer {
    private String name;
    private String email;

    class Address {
        String street;
        String city;
        String postalCode;
    }
}

// Corrected: Address is a top-level record. Its definition depends on
// the concept of an address, not on which entity happens to have one.
// The same Address type is reusable across Customer, Supplier, Shipment,
// serializable on its own, and testable without any outer instance.
record Address(String street, String city, String postalCode) {}

class Customer {
    private String name;
    private String email;
    private Address address;
}
Enter fullscreen mode Exit fullscreen mode

Essential coupling: the address schema (street, city, postal code) and whatever validation or formatting rules apply to addresses.
Accidental coupling introduced by the non-static inner class: a compiler-synthesized this$0 field pointing at a Customer instance, plus the constructor parameter that populates it. Address's body never references either; the compiler emits them because the JLS requires them of every non-static inner class, regardless of whether the body uses them.
The top-level record removes those artifacts entirely — no enclosing-instance field, no constructor parameter for one, no binding to any Customer contract. Every Address now depends on the essentials and nothing more.

Static nested class

A static nested class has no implicit outer reference and no coupling to the enclosing instance.
Its driver set is its own.
Use it when code needs to reside inside the enclosing type for visibility reasons — package-private access, logical grouping — but varies independently of the outer class.
If a non-static inner class never references the outer this, promoting it to static removes the compiler-synthesized outer reference at no other cost.

A linked-list Node is the canonical case.
Each node stores a value and a pointer to the next node — nothing it does depends on the particular list instance it belongs to, and promoting it from non-static to static lets the node be used across list instances, serialized independently, and tested in isolation:

// Before: Node is non-static. It implicitly shares LinkedList's
// type parameter T, but the compiler also adds a synthetic this$0
// field of type LinkedList<T> to every node instance — even though
// Node's body does not depend on the list it happens to live in.
class LinkedList<T> {
    Node first;   // implicitly Node of the enclosing LinkedList<T>
    Node last;

    class Node {
        T value;
        Node next;

        Node(T value) { this.value = value; }
    }

    void add(T value) { /* ... */ }
}

// After: Node is static and generic in its own right. It carries
// only value + next; no synthetic enclosing-instance reference,
// no dependency on any particular list.
class LinkedList<T> {
    Node<T> first;
    Node<T> last;

    static class Node<E> {
        E value;
        Node<E> next;

        Node(E value) { this.value = value; }
    }

    void add(T value) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Essential coupling: a value cell and a pointer to the next node.
Accidental coupling introduced by the non-static form: a this$0 field pointing at a specific LinkedList instance, and its constructor parameter. Node's body needs neither — a node's behavior does not change based on which list it happens to be in.
The static form keeps only the essential artifacts. This is the choice java.util.LinkedList makes in the JDK source: its private Node is static, for the same structural reason.

Lambda

A lambda closes over exactly what its body references.
Its driver set contains only the drivers of what it explicitly captures.
A this reference inside the lambda body lexically binds to the enclosing instance, not to any identity the lambda introduces, and the enclosing instance is captured only when the body actually uses it.
Lambdas are the correct form for single-operation, stateless behavior.

// Both forms sit in a non-static context (an instance method on some service).
// batchId is a local value — captured by reference by whichever form we pick.
final long batchId = nextBatchId();

// Before: anonymous class — the compiler emits a synthetic class file
// (Outer$1.class), and because we are in a non-static context every
// instance also carries an implicit Outer.this field alongside the
// captured batchId, regardless of whether run() ever uses it.
executor.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("processing batch " + batchId);
    }
});

// After: lambda — the compiler extracts the body into a private static
// method; LambdaMetafactory generates a hidden class (no .class file on
// disk) holding only batchId. No synthetic Outer.this field is added,
// because the body does not reference anything on the enclosing instance.
executor.submit(() -> System.out.println("processing batch " + batchId));
Enter fullscreen mode Exit fullscreen mode

Essential coupling: the captured batchId value and whatever the body actually references.
Accidental coupling introduced by the anonymous class: a this$0 field pointing at the enclosing instance, emitted whether or not run() uses it, because the anonymous class sits in a non-static context.
The lambda pays for the enclosing-instance reference only when the body actually needs it — if the body touched this.someField, the compiler would emit the lambda body as an instance method and bind the receiver; when it does not, as here, nothing is bound. The anonymous class binds unconditionally.

The JVM reflects this structurally.
Lambdas are not compiled as anonymous classes.
The compiler extracts the body into a private static method in the enclosing class, then emits an invokedynamic instruction at the lambda site.
At runtime, LambdaMetafactory generates a hidden class — a class with no classloader-visible name, no .class file on disk, eligible for garbage collection when no longer referenced — that implements the target SAM interface backed by the static method.
No outer instance is captured unless the body actually uses it.

A SAM interface (Single Abstract Method) is any interface with exactly one abstract method: Runnable, Comparator<T>, Predicate<T>, Function<A,B>.
Any lambda can be used wherever such an interface is expected.

Method reference

A method reference (ClassName::method, instance::method) introduces no new structural unit.
The referenced method already exists with its own driver set; the reference passes behavior without adding coupling.
Prefer it over a delegating lambda that does nothing but forward the call:

// Delegating lambda — adds nothing, obscures the reference
list.forEach(x -> logger.log(x));

// Method reference — explicit, no additional coupling
list.forEach(logger::log);
Enter fullscreen mode Exit fullscreen mode

Essential coupling: the existing Logger.log behavior, for each element of the list.
Accidental coupling introduced by the delegating lambda: a wrapper method on the enclosing class whose only purpose is to forward x into logger.log(x).
The method reference skips the wrapper — the compiler binds invokedynamic directly to Logger.log with logger as the bound receiver. The wrapper artifact disappears from the compiled output, and so does anything that might make the wrapper itself a maintenance liability.

Anonymous class

An anonymous class generates a named .class file, always captures OuterClass.this in a non-static context, and carries the full infrastructure of a class definition.
The situation that genuinely warrants one — needing state, multiple methods, and no desire for a named type, simultaneously — is less common in Java 25 than it was pre-lambda, though it still arises in listener-heavy and UI codebases.
When it arises, a static nested class with a descriptive name communicates intent more clearly.
For the common case of implementing a single-method interface with no state, a lambda is structurally correct.

// Before: anonymous class — a synthetic class file plus, in a non-static
// context, an implicit Outer.this reference that the comparator's body
// does not use.
Comparator<Order> byPriority = new Comparator<Order>() {
    @Override
    public int compare(Order a, Order b) {
        return Integer.compare(b.priority(), a.priority());
    }
};

// After: lambda — same behavior, no synthetic class file, no Outer.this
// unless the body references it. Driver set is bounded to what the body
// actually uses: Order.priority() and Integer.compare.
Comparator<Order> byPriority = (a, b) -> Integer.compare(b.priority(), a.priority());
Enter fullscreen mode Exit fullscreen mode

Essential coupling: the two Order arguments and the priority comparison.
Accidental coupling introduced by the anonymous class: a this$0 field pointing at the enclosing instance, emitted because the comparator sits in a non-static context, even though compare never references it.
The lambda removes the enclosing-instance binding. A separate, orthogonal refactor replaces the body with Comparator.comparingInt(Order::priority).reversed(); that is a library-idiom choice and does not change the accidental-coupling picture — the lambda form already removed the accidental part.

Record

A record is an immutable data carrier whose driver set is bounded to the drivers of its declared components.
The only things that can force a record to change are changes to the identity or semantics of those components.

Mutability introduces a hidden class of change drivers: anything that writes to a mutable field becomes a change driver for every reader of that field.
In a shared mutable object, the driver set of every holder is infected by the write patterns of every other holder.
Records eliminate this class of coupling by construction.

// Mutable: any writer is a change driver for any reader
class Money {
    BigDecimal amount;
    Currency currency;
}

// Record: driver set bounded to amount and currency semantics
record Money(BigDecimal amount, Currency currency) {}
Enter fullscreen mode Exit fullscreen mode

Essential coupling: an amount and a currency, and whatever operations the domain needs on them.
Accidental coupling introduced by the mutable class: a mutation channel — every holder of a Money reference is coupled to every other holder's write pattern, because the language lets any code with a reference rewrite the fields. Synchronization discipline exists only in convention, not in the class.
The record closes the mutation channel at the language level. The fields are final, construction is canonical, and no write path exists for other holders to cause surprises on this one. The remaining coupling is exactly what the domain requires.

Java 25 records support compact constructors, custom accessor methods, and implements clauses, handling most data-carrier needs that previously required a hand-written immutable class.

Sealed interface with pattern matching

A sealed interface restricts which classes can implement it, making the driver space explicit and finite.
An instanceof chain imports the driver set of every concrete type into the caller and silently becomes incomplete when a new subtype is added:

// Caller's driver set grows to include the union of Γ(Circle), Γ(Rectangle), Γ(Triangle)
if (shape instanceof Circle c) { ... }
else if (shape instanceof Rectangle r) { ... }
// New subtype added later — silently falls through
Enter fullscreen mode Exit fullscreen mode

A sealed hierarchy with exhaustive pattern matching bounds the caller's coupling to the interface contract.
The compiler enforces exhaustiveness: adding a permitted subtype requires every switch site to acknowledge it.

// Caller's driver set is Γ(Shape) — the sealed interface only
switch (shape) {
    case Circle c    -> ...
    case Rectangle r -> ...
    case Triangle t  -> ...  // required once Triangle is permitted
}
Enter fullscreen mode Exit fullscreen mode

Java 25 pattern matching in switch covers deconstruction patterns, guarded cases, and primitive patterns, giving sealed hierarchies the expressive range to handle realistic variant spaces.

Essential coupling: the caller varies with each permitted Shape subtype — that is unavoidable and equally present in both forms.
Accidental coupling introduced by the instanceof chain: the risk that a new subtype is added somewhere in the codebase and the chain silently misses it. That failure mode is not in the shape of the code itself — it is in the gap between what the compiler checks and what the source says.
The sealed switch closes the gap: the compiler knows the permitted subtypes from the sealed ... permits ... declaration and refuses to compile the switch until every permitted subtype is either handled or covered by a default. A new variant forces a visible compile error rather than a silent runtime fall-through. The coupling to the variants remains essential; the accidental failure mode is gone.

Optional and Result

In plain Java without static nullability tooling, null is an invisible driver: callers must defensively check for it without any compile-time signal that the absent case exists.
(JSpecify, the Checker Framework, and NullAway have been closing this gap at the annotation level; Optional<T> closes it at the type level, which is what follows here.)
Optional<T> makes the driver explicit in the type and propagatable via map and flatMap:

// null: absent-case driver invisible at call site
String city = user.getAddress().getCity(); // NullPointerException possible

// Optional: driver explicit, propagation compositional
Optional.ofNullable(user)
    .map(User::getAddress)
    .map(Address::getCity)
    .ifPresent(this::processCity);
Enter fullscreen mode Exit fullscreen mode

A checked exception imposes the same problem on error conditions: every intermediate caller in the stack must declare it, importing the error driver regardless of whether that caller handles it.
A Result<T, E> type — not part of the JDK, but available via Vavr or straightforward to write by hand — makes the error a value.
Intermediate callers propagate it via map and flatMap; only the caller that actually handles the error depends on the error type's change drivers.

Result<T, E> is the right tool in two situations.
First, when the failure is a domain concern the caller should branch on — a payment decline, a validation failure, a lookup miss with multiple possible reasons.
Second, when your module sits on a boundary and an upstream system can hand you values it contractually should not — a null where the documentation promised non-null, a response with a missing required field, a database row with a broken invariant.
In both cases the failure is data from your module's perspective: you cannot fix the upstream, you can only observe it and give your caller a typed value to decide with.
The discriminator is trust: a boundary is the line past which you cannot assume contracts hold, and Result is the shape that lets the boundary absorb a violation without propagating a surprise into deeper code.

For bugs in your own code — invariants your module was supposed to enforce and did not, unreachable code that was reached, impossible states, broken casts — an unchecked exception remains the right form.
The JVM's exception machinery is built for this case: it preserves a stack trace at the point of violation, propagates loudly to a top-level handler, and does not force innocent intermediate callers to pattern-match on a failure that means their collaborator is broken.
Wrapping a bug in a Result.Failure loses the stack trace, silently carries the broken state through combinator chains, and delays the crash past the point where it would have told you what went wrong.
The trade-off is real: Result gives up stack traces, demands a dependency or a hand-rolled type, and requires the team to adopt a paradigm shift around error handling — all of which is worth it for expected failures at boundaries, and none of which is worth it for bugs you actually want to find.

Enum

An enum is the correct construct when the driver space itself is finite and closed, the variants carry no per-instance state, and the entire variant set is a stable fact about the domain (days of the week, HTTP method verbs, file open modes).
When the variants need per-instance fields or independent evolution, a sealed interface over records is the better fit: each permitted record carries its own drivers, composition is free, and new variants can be added without touching a shared constant declaration.
The dividing line is stateful variance: enums for closed stateless sets, sealed hierarchies for closed stateful ones.

The direction of Java's evolution

Each major Java addition since Java 8 has moved toward constructs that reduce the gap between what the language forces you to couple to and what the structural situation requires.
Lambdas gave single-operation behavior a minimum-footprint form.
Optional made absence drivers explicit in the type.
Records eliminated mutable-state coupling.
Sealed interfaces bounded the driver space to the declared set.
Pattern matching made exhaustive dispatch over sealed driver spaces compiler-enforced.
Hidden classes removed the last artifact of the anonymous-class era from lambda compilation.

Read through IVP's lens, the direction is legible: each addition gives developers a way to express a structural situation with a narrower set of compiled artifacts than the construct it replaces, which is the same thing as saying less accidental coupling.
Whether every JEP was deliberately motivated by coupling analysis is beside the point — the effect, observed across the language's evolution, is that the gap between what the constructs force you to couple to and what the situation requires has narrowed.
Framework and ecosystem realities push in the other direction: JPA entities still require mutability and no-arg constructors, reflection-based serialization libraries still call setters, Spring-managed beans still participate in proxy machinery that forbids final.
The language's direction is visible, but the ecosystem constrains which parts of it a given codebase can adopt.
The practical split in a typical Java 25 service is records for value objects and DTOs, sealed interfaces for domain hierarchies the developer fully controls, and mutable classes for entities and beans the framework manages.

The decision

The question is always the same: does this construct force more coupling than the situation requires?
The answer determines the choice.
If the code needs the outer instance's state, a non-static inner class is warranted.
If it needs state or multiple methods but not the outer instance, a static nested class is correct.
If it needs neither, a lambda is the right form.
Records replace mutable data carriers.
Sealed interfaces replace open hierarchies when the variant space is closed.
Optional and Result replace invisible null and exception drivers with explicit types.
Each substitution is structural, and the structure is what determines how much the code costs to change.

Source: dev.to

arrow_back Back to Tutorials