Module 4 · Methods & Modularity ⏱ 19 min

Immutability, Side Effects, Defensive Copying

By the end of this lesson you will be able to:
  • Predict whether a method call will mutate an object or create a new one
  • Explain why immutable objects prevent accidental side effects
  • Recognise when defensive copying protects internal state

Imagine you write a Person class with a getFriends() method that returns the internal list of friends. You hand that list to a report generator, and later you discover the generator added a few names — your internal list has been corrupted by code you never intended to touch. That is a side effect: a method changed state that belonged to someone else. Side effects are the reason large programs become impossible to reason about: you cannot trust a method to do only what its name promises, because it might be reaching out and altering data that lives far away. Java gives you two weapons against this chaos: immutability — objects that cannot change once built — and defensive copying — returning a duplicate so the original stays safe.

flowchart LR
  GET["getFriends()"] --> LIST["internal List"]
  CALLER["caller"] --> LIST
  CALLER -.->|"adds names"| LIST
  LIST -.->|"corrupted"| GET
  style LIST fill:#b45309,color:#fff
A leaky getter hands out the internal mutable object. A side effect in one place corrupts state everywhere that object is shared.

Immutability: the object that never changes

Some classes in Java are designed so that every method that looks like modification actually builds a brand-new object. String is the most important example, and every Java program relies on its immutability. When you write String greeting = "Hello"; greeting = greeting + "!";, the + operator does not modify the original "Hello" string in place. It allocates a new "Hello!" string somewhere else in memory and repoints the variable. The old "Hello" still exists in memory, unchanged, until the garbage collector eventually reclaims it. Because String never changes, you can pass it to any method, store it in any field, or share it across threads with zero risk that the recipient will corrupt your copy. That safety is why String is the default choice for text.

Main.java — String concatenation creates a new object; the original is untouched. Real, compiling Java.
public class Main {
    public static void main(String[] args) {
        String greeting = "Hello";
        String updated = greeting + "!";
        System.out.println("original: " + greeting);
        System.out.println("updated: " + updated);
    }
}

Mutability: power with responsibility

Not every object is immutable. StringBuilder, ArrayList, and arrays are all mutable — they expose methods that modify their internal state in place. That power is useful: appending to a StringBuilder is far faster than concatenating String objects in a loop. But it is also dangerous. Hand a mutable object to another method and you are implicitly granting permission to mutate it. If that method appends, removes, or sorts, your original object changes with it. There is no compiler warning; the change simply happens. The receiver did not ask for ownership; it was handed ownership by default, and that is the trap.

Main.java — a mutable StringBuilder is mutated through a method parameter. Real, compiling Java.
public class Main {
    static void addExclaim(StringBuilder sb) {
        sb.append("!");
    }

    public static void main(String[] args) {
        StringBuilder msg = new StringBuilder("hi");
        addExclaim(msg);
        System.out.println("msg: " + msg);
    }
}
flowchart LR
  subgraph IMM["Immutable String"]
    S1["greeting"] --> O1["Hello"]
    S2["greeting"] --> O2["Hello!"]
  end
  subgraph MUT["Mutable StringBuilder"]
    T1["msg"] --> O3["hi"]
    O3 -.->|"append() changes same object"| O3
  end
String creates a new object for every change; StringBuilder modifies the same object in place.

Arrays: the hidden risk

Arrays are the most commonly leaked mutable objects in beginner code. Because they are built into the language and have no methods you call with dots, it is easy to forget they are objects with reference semantics. Passing an int[] to a helper method hands over a live reference to your internal data. Even Arrays.sort mutates the array in place. If you need the original order preserved, you must copy the array before sorting. Treat every array as a loaded gun: powerful, but dangerous the moment it leaves your hands. The compiler will not warn you; the bug will surface only when data changes mysteriously.

Defensive copying: trust but verify

When a class stores a mutable object — say an array or a list — and exposes it through a getter, it is giving away the keys to its internal state. Any caller with that reference can mutate, replace, or clear the data, and your object will have no idea it happened. The fix is defensive copying: instead of returning the internal array, return a copy of it. Instead of storing a caller's array directly in your constructor, store a copy. The copy costs a little extra time and memory, but it buys you a clear contract: no outside code can corrupt your internals, and your internals cannot leak out and surprise callers. Java's own ArrayList and HashMap use exactly this strategy at their boundaries. It is the standard practice in every well-designed Java API, and it is the first thing experienced reviewers check for in a pull request.

Main.java — defensive copies in constructor and getter keep internal state safe. Real, compiling Java.
public class Main {
    static class ScoreBoard {
        private int[] scores;

        ScoreBoard(int[] scores) {
            this.scores = scores.clone();
        }

        int[] getScores() {
            return scores.clone();
        }
    }

    public static void main(String[] args) {
        int[] data = {80, 90};
        ScoreBoard board = new ScoreBoard(data);
        int[] got = board.getScores();
        got[0] = 99;
        System.out.println("got[0]: " + got[0]);
        System.out.println("internal: " + board.getScores()[0]);
        System.out.println("original data: " + data[0]);
    }
}
flowchart LR
  GET["getScores()"] --> COPY["copy of array"]
  CALLER["caller"] --> COPY
  COPY -.->|"mutates"| COPY
  INT["internal array"] -.->|"safe"| INT
  style INT fill:#3776ab,color:#fff
A defensive getter returns a copy. The caller can mutate the copy without touching the internal array.

Choosing your strategy

Use immutable objects when you can — they eliminate entire categories of bug and make your code safe to share across methods and threads. Use mutable objects when performance or API design demands it, but keep them private to the class that owns them. Use defensive copies at the boundaries of your class: accept copies in constructors and setters, and return copies in getters. The rule is simple — never let a mutable object enter or leave your class without asking whether you have handed over the original or a duplicate. The combination is how Java's own libraries stay safe, and it is how you write code that other engineers can trust without reading every line.

A pattern to recognise

In technical interviews and certification exams, one question appears constantly: a String is passed to a method and apparently modified, yet the caller's variable is unchanged. The answer is always the same — String is immutable, so the method built a new object and left the original intact. If the same question uses a StringBuilder or an array, the answer flips: the caller sees the change because the object was mutable and shared. Recognising that pattern quickly is a reliable signal that you have moved from syntax to real understanding.

Exercise

Immutable String. Concatenation creates a new object. Predict both lines.

public class Main {
    public static void main(String[] args) {
        String name = "Ada";
        String greet = name + "!";
        System.out.println(name);
        System.out.println(greet);
    }
}
Exercise

Mutable StringBuilder. A method appends to a StringBuilder. Predict the single line.

public class Main {
    static void addWorld(StringBuilder sb) {
        sb.append(" world");
    }

    public static void main(String[] args) {
        StringBuilder msg = new StringBuilder("hi");
        addWorld(msg);
        System.out.println(msg);
    }
}
Exercise

Defensive copy. A class clones the array in its constructor and getter. Predict all three lines.

public class Main {
    static class Safe {
        private int[] nums;

        Safe(int[] nums) {
            this.nums = nums.clone();
        }

        int[] getNums() {
            return nums.clone();
        }
    }

    public static void main(String[] args) {
        int[] data = {1, 2};
        Safe s = new Safe(data);
        int[] out = s.getNums();
        out[0] = 99;
        System.out.println(out[0]);
        System.out.println(s.getNums()[0]);
        System.out.println(data[0]);
    }
}
Exercise

Which of these types is immutable?

Exercise

Why does a well-designed class use defensive copying in its getters and setters?

Recap

  • Immutable objects like String never change; methods that look like modification create new objects.
  • Mutable objects like StringBuilder and arrays can change in place, so sharing them creates side effects.
  • A side effect is when one method corrupts state that belongs to another.
  • Defensive copying returns or stores a duplicate, keeping the original safe from outside mutation.
  • Apply defensive copies at class boundaries: getters, setters, and constructors that accept mutable objects.

Checkpoint quiz

What happens when you concatenate two String objects with +?

Why should a getter return a copy of an internal array instead of the array itself?

Go deeper — technical resources