Chapter 3: Classes and Interfaces

effective-java classes-and-interfaces java best-practices

Book: Effective Java, 3rd Edition — Joshua Bloch
Status: 🟩 Complete
Difficulty: Medium
Items: 15-25 (11 items)
Time to complete: ~45 min


Overview

Chapter 3 addresses how to design the building blocks of Java programs — classes and interfaces — so they are robust, flexible, and easy to use correctly. The items here cover information hiding (accessibility), immutability, inheritance vs. composition, and the right use of Java’s type system. Together they form the core of object-oriented design in Java, distilling decades of experience into actionable rules. Getting these decisions right at the design stage is far cheaper than refactoring later; getting them wrong can lock your API into bad choices forever. Java 17 introduces records and sealed classes/interfaces that make several of these best practices first-class language features.


Items

Item 15: Minimize the accessibility of classes and members

The Problem

Overly permissive access makes internals visible to the world, preventing future refactoring and creating unintended coupling. A common mistake is making fields public for convenience, or leaving a helper class package-private when it should be private.

// BAD: public mutable array — callers can modify contents
public class SecurityManager {
    public static final String[] VALID_ROLES = { "ADMIN", "USER", "GUEST" };
}
 
// Caller can silently corrupt state:
SecurityManager.VALID_ROLES[0] = "HACKER";

The Solution

Apply the principle of least privilege: every class and member should be as inaccessible as possible. Use private by default, expose only what is part of the public API.

// GOOD: immutable list exposed via accessor
public class SecurityManager {
    private static final String[] VALID_ROLES_ARRAY = { "ADMIN", "USER", "GUEST" };
 
    // Option 1: defensive copy
    public static String[] validRoles() {
        return VALID_ROLES_ARRAY.clone();
    }
 
    // Option 2: unmodifiable list (preferred)
    public static final List<String> VALID_ROLES =
        Collections.unmodifiableList(Arrays.asList(VALID_ROLES_ARRAY));
}

Why This Works

Information hiding is the cornerstone of modular design. Inaccessible internals can be changed freely without breaking clients. It decouples modules, speeds up development (modules can be built in parallel), eases testing, and reduces the risk of misuse.

Accessibility levels (most to least restrictive):

ModifierAccessible from
privateThe class itself
package-private (default)Any class in the same package
protectedSubclasses + same package
publicEverywhere

When to Apply / When NOT to Apply

  • Apply to all new code by default — start private and widen only as needed.
  • Do NOT make a member public just to enable testing; instead, make it package-private and put the test in the same package.
  • Public static final fields must reference immutable objects only. Arrays and mutable collections are never safe as public static final.

Java 17 Update

No direct language change, but records (Java 16+) enforce private-final fields automatically — you cannot declare a record component with a looser access modifier. This makes Item 15 compliance automatic for value-like classes.


Item 16: In public classes, use accessor methods, not public fields

The Problem

Exposing fields directly in public classes robs you of the ability to change the representation, enforce invariants, or take action when the field is accessed.

// BAD: degenerately simple, but locks you in forever
public class Point {
    public double x;
    public double y;
}
 
// You can never:
// - validate that x and y are finite
// - lazily compute derived values
// - change storage to polar coordinates
// - add change listeners

The Solution

Always use accessor methods (getters) and, if the class is mutable, mutators (setters).

// GOOD
public class Point {
    private double x;
    private double y;
 
    public Point(double x, double y) {
        if (!Double.isFinite(x) || !Double.isFinite(y))
            throw new IllegalArgumentException("Coordinates must be finite");
        this.x = x;
        this.y = y;
    }
 
    public double x() { return x; }  // Java 14+ style (no "get" prefix)
    public double y() { return y; }
 
    public void setX(double x) {
        if (!Double.isFinite(x)) throw new IllegalArgumentException();
        this.x = x;
    }
}

Why This Works

Accessor methods give you a stable interface while the implementation stays flexible. You can add validation, caching, notifications, or even switch from storing x/y to r/theta without changing callers.

Exception — package-private and private nested classes: It is fine to expose fields directly in these cases because the exposure is limited to a small, controlled scope. java.awt.Point is a historical example of getting this wrong in a public class.

When to Apply / When NOT to Apply

  • Always use accessors in public classes.
  • Package-private classes are fine with public fields if it reduces boilerplate without risk.
  • Do NOT use the JavaBeans getX()/setX() style in new APIs — prefer the shorter x()/x(double) style or records.

Java 17 Update

Records solve this entirely for immutable value classes:

// Java 16+: accessor methods generated automatically, fields private final
public record Point(double x, double y) {
    // compact constructor for validation
    public Point {
        if (!Double.isFinite(x) || !Double.isFinite(y))
            throw new IllegalArgumentException("Coordinates must be finite");
    }
}
 
Point p = new Point(1.0, 2.0);
p.x(); // accessor — not p.getX()

Item 17: Minimize mutability

The Problem

Mutable classes are harder to reason about, prone to bugs in multithreaded code, and cannot be freely shared. The classic mistake is creating a mutable class when an immutable one would do.

// BAD: mutable date/time value — source of countless bugs
public class MutablePeriod {
    private Date start;
    private Date end;
 
    public void setStart(Date start) { this.start = start; }
    public void setEnd(Date end)     { this.end = end;     }
 
    // No invariant enforcement — anyone can set end before start
}

The Solution

Follow the five rules of immutability:

  1. Don’t provide methods that modify state (no setters).
  2. Ensure the class can’t be extended (declare final or use a private constructor + static factory).
  3. Make all fields final.
  4. Make all fields private.
  5. Ensure exclusive access to mutable components (defensive copies in constructors and accessors).
// GOOD: immutable Period class
public final class Period {
    private final Instant start;
    private final Instant end;
 
    public Period(Instant start, Instant end) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
        // Instant is immutable — no defensive copy needed
        this.start = start;
        this.end   = end;
    }
 
    public Instant start() { return start; }
    public Instant end()   { return end;   }
 
    // "Wither" pattern for derived values
    public Period withStart(Instant newStart) {
        return new Period(newStart, end);
    }
 
    @Override public String toString() {
        return start + " - " + end;
    }
}

Why This Works

Immutable objects are inherently thread-safe — they require no synchronization. They can be shared freely, used as map keys or set elements without risk of corruption, and are easier to test and reason about. The only real downside is that a distinct object is required for each distinct value, which can be costly for large objects (mitigated with companion mutable builders, e.g., String/StringBuilder).

When to Apply / When NOT to Apply

  • Make every class immutable unless there is a strong reason for mutability.
  • If a class must be mutable, limit mutability as much as possible (minimize the number of states).
  • Classes like java.util.Date and java.awt.Point are historical mistakes — prefer Instant, LocalDate, etc.

Java 17 Update

Records (Java 16+) are immutable by default and require no boilerplate:

// Java 16+: Period as a record — all five immutability rules satisfied automatically
public record Period(Instant start, Instant end) {
    public Period {
        Objects.requireNonNull(start, "start");
        Objects.requireNonNull(end, "end");
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(start + " after " + end);
    }
}

Records cannot be extended, all components are private final, and canonical accessor methods are generated. The “wither” pattern requires manual implementation, but the boilerplate savings are enormous.


Item 18: Favor composition over inheritance

The Problem

Inheritance across package boundaries is fragile. Subclasses are tightly coupled to the implementation details of their superclass, and superclass changes can break subclasses silently. The classic illustration: extending HashSet to count how many elements were ever added.

// BAD: fragile subclass — breaks because addAll() calls add() internally
public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;
 
    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
 
    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();      // BUG: each element also goes through add()
        return super.addAll(c);   // which increments addCount again!
    }
 
    public int getAddCount() { return addCount; }
}
 
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("a", "b", "c"));
s.getAddCount(); // returns 6, not 3 — double-counting!

The Solution

Use composition: hold a reference to the wrapped object (the forwarding pattern). Implement the same interface by delegating all calls to the wrapped instance. This is the Decorator design pattern.

// GOOD: forwarding class (reusable)
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
 
    public ForwardingSet(Set<E> s) { this.s = s; }
 
    @Override public boolean add(E e)                           { return s.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override public boolean remove(Object o)                  { return s.remove(o); }
    @Override public int size()                                { return s.size(); }
    @Override public boolean isEmpty()                         { return s.isEmpty(); }
    @Override public boolean contains(Object o)                { return s.contains(o); }
    @Override public Iterator<E> iterator()                    { return s.iterator(); }
    @Override public Object[] toArray()                        { return s.toArray(); }
    @Override public <T> T[] toArray(T[] a)                    { return s.toArray(a); }
    @Override public boolean containsAll(Collection<?> c)      { return s.containsAll(c); }
    @Override public boolean removeAll(Collection<?> c)        { return s.removeAll(c); }
    @Override public boolean retainAll(Collection<?> c)        { return s.retainAll(c); }
    @Override public void clear()                              { s.clear(); }
    @Override public boolean equals(Object o)                  { return s.equals(o); }
    @Override public int hashCode()                            { return s.hashCode(); }
    @Override public String toString()                         { return s.toString(); }
}
 
// GOOD: wrapper (decorator) class — adds instrumentation without fragility
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
 
    public InstrumentedSet(Set<E> s) { super(s); }
 
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
 
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);  // delegates — does NOT call our overridden add()
    }
 
    public int getAddCount() { return addCount; }
}
 
// Works correctly with ANY Set implementation:
InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
s.addAll(List.of("a", "b", "c"));
s.getAddCount(); // returns 3 — correct!
 
// Can even wrap a TreeSet transparently:
InstrumentedSet<String> sorted = new InstrumentedSet<>(new TreeSet<>());

Why This Works

The wrapper class has no knowledge of the inner Set’s implementation details. Changes to HashSet cannot break InstrumentedSet. The ForwardingSet can be reused to build other decorators. No self-use dependency exists between add and addAll in our layer.

When to Apply / When NOT to Apply

  • Use inheritance only when a genuine is-a relationship exists and you control both classes.
  • Use composition when you want to add behavior to an existing class that you don’t control.
  • Be careful: the Guava library provides many Forwarding* classes for exactly this pattern.
  • Composition has a minor drawback: the wrapper object is not equal to the wrapped object. This matters when equality semantics based on identity are required (rare in practice).

Java 17 Update

No direct change, but sealed interfaces make it easier to document when a hierarchy is closed, removing one motivation for inheritance of convenience.


Item 19: Design and document for inheritance or else prohibit it

The Problem

Designing a class to be safely subclassed is hard. If you don’t think about it, subclasses can easily break in subtle ways when the superclass evolves.

// BAD: undocumented self-use makes this unsafe to override
public class Super {
    public void overrideMe() { }
 
    public Super() {
        overrideMe();  // called in constructor — dangerous!
    }
}
 
public class Sub extends Super {
    private final Instant instant;
 
    public Sub() {
        super();  // calls overrideMe() before instant is initialized
        instant = Instant.now();
    }
 
    @Override
    public void overrideMe() {
        System.out.println(instant);  // prints null on first call!
    }
}

The Solution

Either design for inheritance with full documentation of self-use (which methods call which), or prohibit inheritance by making the class final or all constructors private/package-private.

// GOOD: explicit self-use documentation with hooks
/**
 * ...
 * <p>Implementors must override {@code buildHeader} and {@code buildBody}.
 *
 * @implSpec This implementation calls {@link #buildHeader()} then
 * {@link #buildBody()} during {@link #render()}.
 */
public abstract class AbstractReport {
 
    // Template method — safe self-use documented via @implSpec
    public final String render() {
        return buildHeader() + "\n" + buildBody();
    }
 
    /** Returns the report header section. */
    protected abstract String buildHeader();
 
    /** Returns the report body section. */
    protected abstract String buildBody();
 
    // No instance fields mutated in constructor
}
 
// GOOD: prohibit inheritance when not designed for it
public final class UtilityHelper {
    private UtilityHelper() { }  // uninstantiable + effectively final
    // ...
}

Key rules when designing for inheritance:

  • Constructors must NOT call overridable methods.
  • If you implement Cloneable or Serializable, neither clone() nor readObject() should call overridable methods.
  • Provide hooks (protected methods) that subclasses can override, but document them.
  • Test by writing at least three subclasses before publishing.

When to Apply / When NOT to Apply

  • Prohibit inheritance (use final) on most concrete classes unless you specifically designed for it.
  • Abstract classes designed for inheritance must document every self-use via @implSpec.
  • Do NOT add new overridable methods to an existing class just to satisfy a subclass’s needs — use composition instead.

Java 17 Update

Sealed classes (Java 17) provide a third option: permit exactly the set of subclasses you have designed for, no more.

// Java 17: sealed class — known subclasses, all within the same module
public sealed class Shape permits Circle, Rectangle, Triangle { }
public final class Circle    extends Shape { }
public final class Rectangle extends Shape { }
public final class Triangle  extends Shape { }

Item 20: Prefer interfaces to abstract classes

The Problem

Abstract classes can only be single-inherited, restricting how implementors can fit into existing class hierarchies. An abstract class forces a rigid subtype relationship.

// BAD: abstract class forces single inheritance
public abstract class AbstractSinger {
    public abstract void sing();
    public void warmUp() { System.out.println("La la la..."); }
}
 
// A class cannot be both a Singer and a Songwriter without a combined abstract class
// The AbstractSingerSongwriter explosion problem:
public abstract class AbstractSingerSongwriter extends AbstractSinger {
    public abstract void compose();
}

The Solution

Use interfaces to define types. Interfaces allow mixins — a class can implement any number of them, composing behaviors freely. Combine interfaces with skeletal implementation classes (abstract classes named Abstract*) for default method implementations.

// GOOD: interfaces for types, skeletal implementation for convenience
public interface Singer {
    void sing();
    default void warmUp() { System.out.println("La la la..."); }
}
 
public interface Songwriter {
    Song compose(int chartPosition);
}
 
// A class can be both — not possible with abstract classes
public class Adele implements Singer, Songwriter {
    @Override public void sing()  { System.out.println("Rolling in the deep..."); }
    @Override public Song compose(int pos) { return new Song("Hello", pos); }
}
 
// Skeletal implementation (abstract class + interface)
public abstract class AbstractSinger implements Singer {
    // Shares implementation without restricting hierarchy
    @Override public void warmUp() {
        System.out.println("Breathing exercises first...");
        System.out.println("La la la...");
    }
    // sing() still abstract
}

Simulated multiple inheritance with skeletal implementations:

// The Map.Entry pattern from the JDK
public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {
    // getKey() and getValue() remain abstract
    // Provides concrete implementations of equals, hashCode, toString, setValue
    @Override public V setValue(V value) {
        throw new UnsupportedOperationException();
    }
 
    @Override public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Map.Entry)) return false;
        Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
        return Objects.equals(e.getKey(), getKey())
            && Objects.equals(e.getValue(), getValue());
    }
 
    @Override public int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }
}

When to Apply / When NOT to Apply

  • Prefer interfaces whenever you are defining a type.
  • Use abstract classes when you need to define template methods with significant shared implementation and the hierarchy is closed.
  • Use a skeletal implementation alongside an interface when you want to provide default implementations without the single-inheritance restriction.

Java 17 Update

Sealed interfaces (Java 17) allow you to restrict which classes implement an interface while still getting the flexibility benefit of interfaces:

// Java 17: sealed interface — explicitly permitted implementors
public sealed interface Shape permits Circle, Rectangle, Triangle {
    double area();
    double perimeter();
}
 
public record Circle(double radius) implements Shape {
    public double area()      { return Math.PI * radius * radius; }
    public double perimeter() { return 2 * Math.PI * radius; }
}
 
public record Rectangle(double width, double height) implements Shape {
    public double area()      { return width * height; }
    public double perimeter() { return 2 * (width + height); }
}

Item 21: Design interfaces for posterity

The Problem

Before Java 8, interfaces were frozen at publication — adding a method broke all implementations. Java 8 introduced default methods to allow interface evolution, but this is not a free pass.

// Default methods can silently break existing implementations
// Java 8 added Collection.removeIf() with a default implementation
// This broke some implementations like SynchronizedCollection — the default
// removeIf() doesn't acquire the lock that synchronizedCollection() uses!
 
// Pre-Java 8 synchronized wrapper (simplified):
class SynchronizedCollection<E> implements Collection<E> {
    private final Collection<E> c;
    private final Object mutex;
    // ... every method synchronized on mutex
 
    // MISSING: removeIf() — inherits the default, which does NOT lock!
    // Concurrent modification can now occur silently
}

The Solution

Think hard before publishing an interface. Default methods should be used for gradual evolution of existing interfaces with known implementations, not as a substitute for careful design.

// GOOD: design the interface thoughtfully upfront
public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    T save(T entity);
    void deleteById(ID id);
 
    // Default method — carefully considered, works for ALL implementations
    default boolean existsById(ID id) {
        return findById(id).isPresent();
    }
}

Rules for adding default methods:

  • Ensure the default implementation works correctly for EVERY existing implementation — this is extremely difficult to guarantee for widely-used interfaces.
  • Do not add default methods to interfaces you don’t control.
  • Test every default method with multiple existing implementations before publishing.

When to Apply / When NOT to Apply

  • Design interfaces with their full intended method set before publishing — changing them after release is very risky.
  • Use default methods sparingly for evolution of existing, widely-deployed interfaces.
  • Do NOT use default methods as a substitute for proper interface design.

Java 17 Update

No major change, but with sealed interfaces you have better control over implementors, making default method reasoning slightly easier (known, finite set of implementations).


Item 22: Use interfaces only to define types

The Problem

The constant interface antipattern: using an interface to export constants (static final fields). This is a misuse of the interface mechanism, leaking implementation details into the class’s public API.

// BAD: constant interface antipattern
public interface PhysicalConstants {
    static final double AVOGADROS_NUMBER   = 6.022_140_857e23;
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    static final double ELECTRON_MASS      = 9.109_383_56e-31;
}
 
// Classes that implement this expose these constants in their API forever
public class SpeedCalculator implements PhysicalConstants {
    // ...
}
// SpeedCalculator.AVOGADROS_NUMBER is now public API — hard to remove

The Solution

Put constants in a utility class (if they’re not associated with a specific type) or directly on the relevant class/enum.

// GOOD: utility class for unrelated constants
public final class PhysicalConstants {
    private PhysicalConstants() { }  // uninstantiable
 
    public static final double AVOGADROS_NUMBER   = 6.022_140_857e23;
    public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    public static final double ELECTRON_MASS      = 9.109_383_56e-31;
}
 
// Use with static import for conciseness
import static com.example.PhysicalConstants.AVOGADROS_NUMBER;
 
public class Atoms {
    double moles(double molecules) { return molecules / AVOGADROS_NUMBER; }
}
 
// BETTER: constants associated with an enum
public enum Planet {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS  (4.869e+24, 6.0518e6),
    EARTH  (5.976e+24, 6.37814e6);
 
    private final double mass;    // kg
    private final double radius;  // meters
    Planet(double mass, double radius) { this.mass = mass; this.radius = radius; }
}

When to Apply / When NOT to Apply

  • Interfaces define types — behavioral contracts that are tested via instanceof.
  • If you find yourself implementing an interface solely to avoid typing the class name, use static imports instead.
  • Use enums for constants that represent a fixed set of named values.

Item 23: Prefer class hierarchies to tagged classes

The Problem

Tagged classes use a field to indicate which “kind” of object an instance is. They are verbose, error-prone, and poor reflections of what a proper class hierarchy should look like.

// BAD: tagged class — two kinds of shapes, distinguished by a tag field
public class Figure {
    enum Shape { RECTANGLE, CIRCLE }
 
    final Shape shape;  // the tag
 
    // Rectangle fields
    double length;
    double width;
 
    // Circle fields
    double radius;
 
    // Rectangle constructor
    Figure(double length, double width) {
        shape  = Shape.RECTANGLE;
        this.length = length;
        this.width  = width;
    }
 
    // Circle constructor
    Figure(double radius) {
        shape  = Shape.CIRCLE;
        this.radius = radius;
    }
 
    double area() {
        switch (shape) {
            case RECTANGLE: return length * width;
            case CIRCLE:    return Math.PI * (radius * radius);
            default:        throw new AssertionError(shape);  // unreachable, but required
        }
    }
}
// Problems: irrelevant fields per instance, boilerplate switch, error-prone addition of new shapes

The Solution

Replace with a class hierarchy — one abstract class with a concrete subclass per variant.

// GOOD: class hierarchy — each shape is its own class
public abstract class Figure {
    abstract double area();
}
 
public class Circle extends Figure {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    @Override public double area() { return Math.PI * radius * radius; }
}
 
public class Rectangle extends Figure {
    private final double length;
    private final double width;
    public Rectangle(double length, double width) {
        this.length = length;
        this.width  = width;
    }
    @Override public double area() { return length * width; }
}
 
// Adding a new shape requires ZERO changes to existing classes:
public class Square extends Rectangle {
    public Square(double side) { super(side, side); }
}

Why This Works

Each class contains only the fields relevant to it. The compiler enforces that each subclass implements area(). Adding a new shape means adding a new class, not modifying a switch statement. No wasted memory, no implicit casts, no default error cases.

When to Apply / When NOT to Apply

  • Replace any class with a discriminator/tag field with a class hierarchy.
  • Not applicable when you genuinely have a single object that plays different roles at different times (rare).

Java 17 Update

Sealed classes (Java 17) + pattern matching for switch (preview in 17, standard in 21) make exhaustive hierarchies first-class:

// Java 17+: sealed hierarchy — compiler-enforced exhaustiveness
public sealed abstract class Figure permits Circle, Rectangle {
    abstract double area();
}
 
public final class Circle    extends Figure { /* ... */ }
public final class Rectangle extends Figure { /* ... */ }
 
// Pattern matching for switch (Java 21, but preview in 17):
double area = switch (figure) {
    case Circle    c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.length() * r.width();
    // No default needed — compiler knows all cases are covered
};

Item 24: Favor static member classes over nonstatic

The Problem

A nonstatic inner class holds a hidden reference to its enclosing instance. This is often unintentional, causes memory leaks, and prevents the inner class from being instantiated without an enclosing instance.

// BAD: nonstatic member class when static would do
public class Outer {
    private int value = 42;
 
    // Nonstatic — holds hidden reference to Outer.this
    public class Inner {
        public void print() {
            System.out.println(value);  // accesses Outer.this.value
        }
    }
}
 
// Instantiation requires an enclosing Outer instance — surprising and wasteful
Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();  // odd syntax
 
// Memory leak: inner cannot be GC'd while outer lives,
// even if you only need inner for a brief computation

The Solution

If the nested class doesn’t need access to the enclosing instance, declare it static.

// GOOD: static member class — no hidden reference
public class Outer {
    private int value = 42;
 
    // Static — cannot access Outer.this, no hidden reference
    public static class Entry<K, V> {
        private final K key;
        private final V value;
 
        public Entry(K key, V value) {
            this.key   = key;
            this.value = value;
        }
 
        public K key()   { return key; }
        public V value() { return value; }
    }
}
 
// No enclosing instance needed
Outer.Entry<String, Integer> e = new Outer.Entry<>("hello", 42);

Four kinds of nested classes (and when to use each):

KindStaticUse When
Static member classYesConceptually part of the enclosing class but independent
Nonstatic member classNoPart of the enclosing instance (Iterator implementations)
Anonymous classNoShort one-shot use (being replaced by lambdas)
Local classNoNamed version of anonymous class, extremely rare

The key rule: If a member class does not require access to an enclosing instance, always make it static. This prevents memory leaks and makes instantiation simpler.

Real-world example: Map.Entry is a static nested interface. Each entry doesn’t need a reference back to the entire Map that contains it.

When to Apply / When NOT to Apply

  • Use nonstatic inner classes for Iterator implementations in collection classes, where each iterator must access the outer collection’s fields.
  • Use static for everything else, including helper classes that model concepts related to the outer class (e.g., Builder in the Builder pattern).

Java 17 Update

Records declared inside a class are implicitly static — Java makes the right choice automatically for you.

public class Processor {
    // Implicitly static — no enclosing instance reference
    public record Result(int code, String message) { }
}

Item 25: Limit source files to a single top-level class

The Problem

Java allows multiple top-level classes in a single source file (only one can be public, but non-public top-level classes are permitted). This is confusing, potentially dangerous, and depends on the order files are passed to the compiler.

// BAD: Main.java defines two top-level classes
// Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + " and " + Dessert.NAME);
    }
}
 
class Utensil { static final String NAME = "pan"; }
class Dessert  { static final String NAME = "cake"; }
 
// Separately, someone creates Dessert.java with a conflicting definition:
// Dessert.java
class Utensil { static final String NAME = "pot"; }
class Dessert  { static final String NAME = "pie"; }
 
// Depending on compilation order:
// javac Main.java Dessert.java  -> "pan and cake"
// javac Dessert.java Main.java  -> "pot and pie"
// javac Main.java              -> "pan and cake"
// Behavior depends on compiler invocation — a maintenance nightmare!

The Solution

One top-level class per source file. If you have non-public classes that are used by only one class, make them static nested classes.

// GOOD: static nested classes — no ambiguity, no file-order dependency
// Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + " and " + Dessert.NAME);
    }
 
    private static class Utensil { static final String NAME = "pan"; }
    private static class Dessert  { static final String NAME = "cake"; }
}

When to Apply

Always. There is no legitimate reason to have multiple top-level classes in a single source file. The rule is absolute.

Java 17 Update

Java 16+ introduced records, which are often small enough that the temptation to group them in one file is strong — resist it. Each record gets its own file.

Exception: as of Java 21, unnamed classes (JEP 463, preview) allow single-file scripts without a class declaration — but that feature is specifically for educational/scripting use, not production code.


Interview Questions & Exercises

Q1: Why is a public static final array a security vulnerability?

Context: Comes up in code reviews, security discussions, and senior Java interviews.

Answer: A public static final reference means the reference itself cannot be reassigned, but arrays are mutable objects. Any caller can modify the array’s contents directly. The final keyword only prevents the field from pointing to a different array — it does NOT make the array’s contents immutable.

// Bug: final reference, mutable contents
public static final String[] ROLES = {"ADMIN", "USER"};
// Attacker:
Main.ROLES[0] = "SUPERUSER";  // succeeds silently!

Fix:

private static final String[] ROLES_PRIVATE = {"ADMIN", "USER"};
public static final List<String> ROLES = Collections.unmodifiableList(
    Arrays.asList(ROLES_PRIVATE));
// Or Java 9+:
public static final List<String> ROLES = List.of("ADMIN", "USER");

Follow-up: “What about Collections.unmodifiableList() wrapping a mutable list — is that safe?” No — modifications to the backing list are still visible through the unmodifiable view. You need a defensive copy first or List.of().


Q2: Explain the fragile base class problem with a concrete example.

Context: OOP design questions, architecture discussions, any senior-level interview.

Answer: A subclass is fragile because it depends on the implementation details of its superclass. When the superclass evolves, the subclass can break without any change to its own code. The canonical example is InstrumentedHashSet (Item 18): overriding addAll() to count additions double-counts because HashSet.addAll() internally calls add(), which the subclass also overrides. The subclass can’t know this without reading the source, and the superclass is free to change this implementation detail in any future release.

The fix is composition: wrap the object rather than extending it. See Item 18 for the InstrumentedSet solution.

Follow-up: “When IS inheritance appropriate?” When there is a genuine is-a relationship, when you control both classes (same package), and when the superclass is designed and documented for inheritance.


Q3: What is the difference between an interface default method and an abstract class method? When would you choose each?

Context: Java 8+ interviews, architectural design questions.

Answer:

  • Default method in an interface: provides a default implementation that implementing classes inherit. A class can implement multiple interfaces, each with defaults. But default methods cannot hold state (no instance fields in interfaces), cannot be synchronized, and should not override Object methods.
  • Abstract class method: can hold state (instance fields), can be synchronized, can use constructors. But a class can only extend one abstract class.

Use an interface when you want a type that can be mixed into any class hierarchy. Use an abstract class (often alongside the interface as a skeletal implementation) when you have significant shared state or implementation that all subclasses must inherit. The combination — interface + Abstract* skeletal implementation — gives you the best of both worlds.

Follow-up: “What happens if a class implements two interfaces that both have a default method with the same signature?” The class must override the method and explicitly resolve the conflict (e.g., call InterfaceA.super.method()).


Q4: How do records change the design of immutable value classes?

Context: Java 16+ interviews, modern Java discussions.

Answer: Records (Java 16) are a first-class language feature for immutable data carriers. They automatically provide: private final fields, a canonical constructor, accessor methods (named after components, not getX()), equals(), hashCode(), and toString(). They cannot extend other classes (implicitly extend Record) and cannot declare instance fields outside of components.

// Pre-Java 16: 30+ lines of boilerplate
public final class Point {
    private final int x;
    private final int y;
    public Point(int x, int y) { this.x = x; this.y = y; }
    public int x() { return x; }
    public int y() { return y; }
    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
    @Override public String toString() { ... }
}
 
// Java 16+: one line
public record Point(int x, int y) { }

Records satisfy Items 15, 16, and 17 automatically. They cannot be used when you need mutability, inheritance, or computed fields that aren’t part of the canonical state.

Follow-up: “Can records have custom validation?” Yes — using the compact constructor:

public record Range(int min, int max) {
    public Range { if (min > max) throw new IllegalArgumentException(); }
}

Q5: What is the constant interface antipattern and why is it harmful?

Context: Code reviews, design discussions, Java API design interviews.

Answer: A constant interface exports constants via static final fields in an interface. Classes that implement the interface inherit these constants without needing to qualify them. This is harmful for three reasons:

  1. It leaks implementation details into the class’s public API — implementors commit to the constants forever.
  2. It pollutes the namespace of every implementing class’s subclasses.
  3. Interfaces represent a type — the instanceof check becomes meaningless (“is this object a PhysicalConstants?”).

The fix: utility class with private constructor and static final fields, accessed via static import.

Follow-up: “What about interfaces that only contain constants, like java.io.ObjectStreamConstants?” That’s a mistake in the JDK — it exists for historical reasons and is considered a design error.


Q6: How do sealed classes in Java 17 improve on the tagged class antipattern?

Context: Java 17 interviews, modern Java design questions.

Answer: Tagged classes use a discriminator field and switch/if-else to handle variants. They are error-prone (default cases, uninitialized fields per variant, explicit casting). Class hierarchies fix this by making each variant a subclass, but they are open — anyone can add a new subclass, making exhaustive switches impossible without a default case.

Sealed classes (Java 17) combine both benefits: they define a closed set of permitted subclasses that the compiler knows about. With pattern matching for switch (Java 21), the compiler can verify switch exhaustiveness — no default needed, and adding a new permitted subclass becomes a compile error in existing switches.

sealed interface Expr permits Num, Add, Mul { }
record Num(int value)       implements Expr { }
record Add(Expr l, Expr r)  implements Expr { }
record Mul(Expr l, Expr r)  implements Expr { }
 
int eval(Expr e) {
    return switch (e) {
        case Num n    -> n.value();
        case Add a    -> eval(a.l()) + eval(a.r());
        case Mul m    -> eval(m.l()) * eval(m.r());
    }; // exhaustive — no default needed
}

Follow-up: “What are the restrictions on permitted subclasses?” They must be in the same package (or module) as the sealed class, and they must be final, sealed, or non-sealed.


Q7: When should you use a nonstatic vs. static nested class?

Context: Memory leak debugging, Java collections internals, OOP design interviews.

Answer: A nonstatic member class holds a hidden reference to the enclosing instance — use it when the nested class genuinely needs access to the outer class’s instance (e.g., an Iterator that traverses an outer collection’s elements). A static member class has no such reference — use it when the nested class is logically associated with the outer class but doesn’t need its state (e.g., a Builder, Map.Entry, or helper class).

The practical rule: if in doubt, make it static. Failing to do so when you should causes memory leaks — the inner class instance prevents the outer instance from being garbage collected, even when the outer instance is no longer needed.

Follow-up: “How do you detect this kind of memory leak?” Using a heap profiler (like JVisualVM or async-profiler) and looking for retained OuterClass$InnerClass instances that should have been collected.


Q8: Explain SOLID principles as they relate to Chapter 3 items.

Context: Senior engineering interviews, architectural discussions.

Answer:

  • Single Responsibility: Item 25 (one class per file) and Item 22 (interfaces for types only, not constants).
  • Open/Closed: Item 18 (composition over inheritance — open for extension via wrapping, closed for modification), Item 23 (class hierarchies — add shapes without changing existing code).
  • Liskov Substitution: Item 19 (design for inheritance — subclasses must not break the superclass contract).
  • Interface Segregation: Item 20 (prefer interfaces — many small interfaces over one large abstract class) and Item 21 (design interfaces carefully upfront).
  • Dependency Inversion: Item 15 (minimize accessibility — depend on abstractions/interfaces, not concrete implementations).

Key Takeaways

  • Make everything as private as possible — widen access only when there is a concrete, documented reason. public static final arrays are always a bug.
  • Immutability is the default — a mutable class should be justified, not an immutable one. Java 16+ records make immutable classes nearly free.
  • Composition over inheritance — use forwarding/wrapper classes (Decorator pattern) when you want to add behavior to an existing class. Inheritance is fragile across package boundaries.
  • Design for inheritance explicitly or prohibit it — never call overridable methods from constructors. Use final on classes not designed for inheritance.
  • Interfaces define types; use class hierarchies for variants — the constant interface antipattern and tagged classes are both signs that you are using the wrong tool.
  • Default methods are not a free lunch — adding a default method to an existing interface can silently break implementations. Design interfaces completely upfront.
  • Static nested classes over nonstatic — every nonstatic inner class holds a hidden reference to its enclosing instance, which can cause memory leaks.
  • Records (Java 16+) satisfy Items 15-17 automatically — for immutable value classes, prefer records over manual implementations.
  • Sealed classes (Java 17) formalize closed hierarchies — use them when you want exhaustive switching without a default clause.
  • One top-level class per source file — no exceptions; multiple top-level classes create compilation-order-dependent behavior.
  • See also: ch02-creating-and-destroying-objects for the Builder pattern (uses static nested classes), ch04-generics for generic types and wildcards.

Last Updated: 2026-05-10