Back to Immutability
Q2Question 2 of 4

Is an immutable class with a mutable field still immutable?

Key Concepts
  • No, unless you use defensive copies – a mutable field can be modified through external references
  • Defensive copy in constructor: this.list = new ArrayList<>(inputList)
  • Defensive copy in getter: return Collections.unmodifiableList(this.list) or return new ArrayList<>(this.list)
  • Without both copies, the caller can modify the internal state and break immutability
  • Understanding this concept is essential for Java interviews at senior level

Definition

No, not without defensive copies. If a mutable field (like a List) is shared with external code, that code can modify the internal state, breaking immutability.

The problem and the fix:

  • Without defensive copy: The constructor stores the caller's mutable reference. The caller can later modify it, changing the object's internal state.
  • Fix in constructor: Copy the mutable input: this.list = new ArrayList<>(input).
  • Fix in getter: Return an unmodifiable view or a copy: return Collections.unmodifiableList(this.list).
  • Both are needed. One without the other still leaks mutability.

If a sealed bank statement holds a reference to the original transaction log instead of a copy, someone who edits the log also edits the statement. The statement is only truly sealed if it contains its own independent copy.

Understanding the Concepts

The broken version

JAVA
public final class BrokenStatement {
    private final List<String> transactions;

    public BrokenStatement(List<String> transactions) {
        this.transactions = transactions;  // NO defensive copy!
    }

    public List<String> getTransactions() {
        return transactions;  // NO defensive copy!
    }
}

List<String> txns = new ArrayList<>(List.of("TX001"));
BrokenStatement stmt = new BrokenStatement(txns);
txns.add("TX002");  // modifies the "immutable" object!
System.out.println(stmt.getTransactions());  // [TX001, TX002] – broken!

stmt.getTransactions().add("TX003");  // also modifies it!
System.out.println(stmt.getTransactions());  // [TX001, TX002, TX003] – still broken!

The correct version

JAVA
public final class SafeStatement {
    private final List<String> transactions;

    public SafeStatement(List<String> transactions) {
        this.transactions = new ArrayList<>(transactions);  // defensive copy IN
    }

    public List<String> getTransactions() {
        return Collections.unmodifiableList(transactions);  // defensive copy OUT
    }
}

List<String> txns = new ArrayList<>(List.of("TX001"));
SafeStatement stmt = new SafeStatement(txns);
txns.add("TX002");  // does NOT affect stmt
System.out.println(stmt.getTransactions());  // [TX001] – safe!

stmt.getTransactions().add("TX003");  // throws UnsupportedOperationException!

What about Date, Calendar, and other mutable JDK types?

JAVA
// java.util.Date is mutable!
public final class SafeRecord {
    private final Date createdAt;

    public SafeRecord(Date createdAt) {
        this.createdAt = new Date(createdAt.getTime());  // defensive copy
    }

    public Date getCreatedAt() {
        return new Date(createdAt.getTime());  // defensive copy
    }
}

// Better: use java.time.Instant (immutable, no copies needed)
public final class BetterRecord {
    private final Instant createdAt;
    public BetterRecord(Instant createdAt) { this.createdAt = createdAt; }
    public Instant getCreatedAt() { return createdAt; }  // Instant is immutable, safe
}

What should a senior engineer know?

  • List.of() returns an immutable list. No defensive copy needed if you store List.of(...) directly.
  • List.copyOf() (Java 10+) creates an unmodifiable copy: this.list = List.copyOf(input);
  • Records do not auto-defensively-copy. record Stmt(List txns) {} – the list is still mutable through stmt.txns().add(...). Add a compact constructor to copy.

What are common mistakes?

A common mistake is not understanding the concept fully before using it in production banking applications.

Wrong approach:

JAVA
package com.bank.service;

public class TransferService {
    public void transfer(String fromAccount, String toAccount, double amount) {
        // WRONG: using double for money
        double balance = getBalance(fromAccount);
        balance = balance - amount;
    }
    private double getBalance(String id) { return 10000.0; }
}

Correct approach:

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class TransferService {
    public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
        // CORRECT: using BigDecimal for money
        BigDecimal balance = getBalance(fromAccount);
        balance = balance.subtract(amount);
    }
    private BigDecimal getBalance(String id) { return new BigDecimal("10000.00"); }
}

Always use BigDecimal for monetary calculations in banking systems to avoid floating-point precision errors.

What are the edge cases?

Edge cases can cause unexpected behavior in banking applications. Here are some to watch for.

JAVA
package com.bank.edge;

public class EdgeCaseDemo {
    public static void main(String[] args) {
        // Edge case 1: null handling
        String accountId = null;
        try {
            System.out.println(accountId.length());
        } catch (NullPointerException e) {
            System.out.println("Null account ID");
        }

        // Edge case 2: empty vs null
        String emptyId = "";
        System.out.println(emptyId.isEmpty());  // true
        System.out.println(emptyId == null);     // false

        // Edge case 3: boundary values
        java.math.BigDecimal zero = java.math.BigDecimal.ZERO;
        java.math.BigDecimal negativeBalance = new java.math.BigDecimal("-0.01");
        System.out.println(zero.compareTo(negativeBalance) > 0);  // true
    }
}
TEXT
Null account ID
true
false
true

Always test with null values, empty strings, boundary values, and negative amounts in banking applications.

What are common interview follow-up questions?

Interviewers often follow up with these related questions:

  • How does this concept apply to a high-throughput banking transaction system?
  • What are the performance implications in a production environment?
  • How would you debug issues related to this concept?
  • What tools would you use to monitor this in production?
  • How does this behave differently in Java 8 vs Java 17+?

When answering follow-up questions, always relate your answer back to practical experience. For example, at National Bank of India, Rajesh debugged a production issue related to this concept by using JFR recordings and heap dumps. Providing concrete examples from banking systems shows the interviewer that you understand both the theory and the practice.

Visual Overview

DIAGRAM
  Broken:  constructor stores caller's list → caller modifies → object state changed!
           getter returns internal list → caller modifies → object state changed!

  Fixed:   constructor: this.list = new ArrayList<>(input)    ← copy IN
           getter: return Collections.unmodifiableList(list)  ← protect OUT

  Both copies needed. One without the other still leaks.

  Best: use immutable types (String, BigDecimal, Instant, List.of())
  → no defensive copies needed at all

Key Takeaway

An immutable class with a mutable field is not truly immutable unless it uses defensive copies. The constructor must copy the mutable input, and the getter must return an unmodifiable view or a copy. Without both, external code can modify the internal state through shared references.

The best approach is to use immutable types (String, BigDecimal, Instant, List.of()) so no defensive copies are needed at all.

One sentence to remember: defensive copy in, defensive copy out – or better, use immutable types so copies are unnecessary.

---

Share
Use ← → arrow keys to navigate