🏛️ Design Principles & Patterns · Advanced

Effective Java essentials

Minimize mutability, defensive copies, prefer interfaces, avoid finalizers, design for inheritance or prohibit it.

🧩 The mysteryYou made a record so it would be immutable. Then someone else's code changed it anyway. Effective Java saw that coming.

Minimize mutability

Immutable objects can't change after construction, so they're thread-safe and simple to reason about. Records help: a record class is final and its fields are private final. But final only freezes the reference, not the object it points to...

record Team(List<String> names) {}
// record: final class
// names: private final field
🔮 Predict it

The leaky record

What does this print?

record Team(List<String> names) {}
void main() {
    var list = new ArrayList<>(List.of("Ali"));
    var t = new Team(list);
    list.clear();
    IO.println(t.names().size());
}
  1. 1
  2. 0
  3. Throws UnsupportedOperationException
Show the answer

The record stored **the same ArrayList the caller still holds. Clearing the caller's list cleared the record's state too. Records store the reference they're given; they don't copy** mutable inputs.

Defensive copies

✗ Shares the caller's list
record Team(List<String> names) {}

Anyone holding the original list can change the team.

✓ Copies on the way in
record Team(List<String> names) {
    Team {
        names = List.copyOf(names);
    }
}

A compact constructor makes an unmodifiable copy, so neither side can change it later.

🔮 Predict it

A constructor calling a hook

Base's constructor calls an overridable method. What does this print?

class Base {
    Base() { init(); }
    void init() {}
}
class Sub extends Base {
    int size = 5;
    void init() { System.out.println(size); }
}
void main() { new Sub(); }
  1. 5
  2. 0
  3. Compile error
  4. Throws NullPointerException
Show the answer

Base() runs before Sub's field initializers, so when the overridden init() runs, size still holds its default 0. With a String field you'd see null. Rule: constructors must not call overridable methods.

Design for inheritance, or prohibit it

Subclassing a class that wasn't designed for it couples the subclass to internal details that may change. Effective Java: either document the hooks carefully, or prohibit inheritance by declaring the class final or giving it only private constructors.

public final class Money {
    private Money(long cents) { ... }
    public static Money of(long c) { ... }
}
⚠️ The trap

Finalizers

finalize() runs unpredictably, maybe never, and can even resurrect objects. Finalization is deprecated for removal. For cleanup, implement AutoCloseable and use try-with-resources, or java.lang.ref.Cleaner as a safety net.

@Override
protected void finalize() { close(); } // no!
 
try (var in = Files.newInputStream(p)) {
    ...
} // closed here, deterministically

Refer to objects by their interfaces

Declare variables, parameters and return types as List<String>, not ArrayList<String>. Then you can swap ArrayList for another List without touching the code that uses it. Declaring the concrete class makes switching *harder*, not easier.

List<String> names = new ArrayList<>();
Map<String, Integer> counts = new HashMap<>();
💼 In the real world

Why teams cite it

Effective Java is one of the most quoted books in Java code reviews. Immutable value objects, defensive copies of mutable inputs and outputs, final classes and try-with-resources prevent whole families of concurrency, security and resource-leak bugs.

Key takeaways

  1. Immutable objects are thread-safe and simpler to reason about
  2. Copy mutable inputs and outputs (lists, arrays, Date)
  3. Declare List<String>, not ArrayList<String>
  4. Use try-with-resources or Cleaner instead of finalize()

💡 Photocopy a contract before filing it, so nobody can change your copy by editing theirs.

🤯 Did you know?

Object.finalize() was deprecated in Java 9, and JEP 421 in Java 18 deprecated finalization for removal altogether.

Practice questions

What does this print?

record Team(List<String> members) {}
void main() {
    var names = new ArrayList<>(List.of("Ali"));
    var t = new Team(names);
    names.add("Sara");
    System.out.println(t.members());
}
  1. [Ali]
  2. Throws UnsupportedOperationException
  3. [Sara]
  4. [Ali, Sara]
Check your answer

[Ali, Sara]. The record stores the reference it was given. The caller still holds the same ArrayList, so changing it changes the 'immutable' record's state.

How do you make this record truly immutable?

record Team(List<String> members) {}
  1. Declare the record final
  2. Mark the members component private final
  3. Add a compact constructor that does members = List.copyOf(members);
  4. Return members.stream() instead of the list
Check your answer

Add a compact constructor that does members = List.copyOf(members);. Records are already final with private final fields; the problem is the shared mutable list. List.copyOf makes an unmodifiable copy, so neither side can change it later.

Next, to close this world: names, small methods, and why returning null is called a billion-dollar habit.