Back to Immutability
Q1Question 1 of 4

How do you create an immutable class in Java?

Key Concepts
  • Make the class final so it cannot be subclassed
  • Make all fields private and final so they cannot be reassigned
  • Do not provide setters – only getters that return the value
  • For mutable fields (List, Date, arrays), return defensive copies in getters
  • Initialize all fields in the constructor, validate inputs
  • Records (Java 16+) do most of this automatically

Definition

An immutable class has final fields, no setters, defensive copies of mutable fields, and is declared final. Once created, its state never changes.

Five rules for immutability:

  1. Class is final – prevents subclassing that could add mutable state.
  2. All fields are private and final – assigned once in the constructor.
  3. No setters – no way to modify state after construction.
  4. Defensive copies – if a field is a mutable type (List, Date), copy it in the constructor and in the getter.
  5. Validate in the constructor – reject invalid inputs at creation time.

An immutable class is like a sealed bank statement. Once printed, the statement cannot be altered. Anyone who receives a copy gets their own print (defensive copy), so marking up their copy does not change the original or anyone else's copy.

Understanding the Concepts

Complete immutable class example

JAVA
import java.math.BigDecimal;
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
import java.util.Objects;

public final class AccountStatement {  // 1. final class
    private final String accountId;         // 2. private final fields
    private final BigDecimal balance;
    private final List<String> transactions;

    public AccountStatement(String accountId, BigDecimal balance, List<String> transactions) {
        // 5. Validate
        this.accountId = Objects.requireNonNull(accountId);
        this.balance = Objects.requireNonNull(balance);
        // 4. Defensive copy in constructor
        this.transactions = new ArrayList<>(Objects.requireNonNull(transactions));
    }

    // 3. Only getters, no setters
    public String getAccountId() { return accountId; }  // String is immutable, safe
    public BigDecimal getBalance() { return balance; }  // BigDecimal is immutable, safe

    // 4. Defensive copy in getter
    public List<String> getTransactions() {
        return Collections.unmodifiableList(transactions);
    }
}

Why each rule matters

JAVA
// Without final class: a subclass could add mutable state
// Without final fields: someone could reassign via reflection
// Without defensive copy:
List<String> txns = new ArrayList<>(List.of("TX001"));
AccountStatement stmt = new AccountStatement("SAV2001", BigDecimal.ZERO, txns);
txns.add("TX002");  // if no defensive copy, this modifies the statement!

Records as immutable classes (Java 16+)

JAVA
// Record: automatically final, private final fields, no setters, equals/hashCode/toString
public record AccountSummary(String accountId, BigDecimal balance) {
    // Compact constructor for validation
    public AccountSummary {
        Objects.requireNonNull(accountId);
        Objects.requireNonNull(balance);
    }
}

// Usage
var summary = new AccountSummary("SAV2001", new BigDecimal("500000"));
// summary.accountId() → "SAV2001"  (no set method exists)

Records handle rules 1-3 and 5 automatically. You still need defensive copies for mutable fields (rule 4).

What should a senior engineer know?

  • String, BigDecimal, LocalDate, Integer are all immutable. Safe to share without copying.
  • Immutable objects are inherently thread-safe. No synchronization needed because state never changes.
  • Use List.of(), Map.of(), Set.of() (Java 9+) for immutable collections.
  • Guava ImmutableList.Builder provides a builder pattern for constructing immutable collections.

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
  5 rules for immutability:
    1. final class           → cannot subclass
    2. private final fields  → cannot reassign
    3. no setters            → cannot modify
    4. defensive copies      → mutable fields isolated
    5. validate in constructor → reject bad input

  Record (Java 16+) automates: 1, 2, 3, 5
  You add: 4 (defensive copies for mutable fields)

Key Takeaway

An immutable class has five properties: the class is final, all fields are private and final, there are no setters, mutable fields are defensively copied in constructors and getters, and inputs are validated at construction time.

Records automate most of this since Java 16. Immutable objects are inherently thread-safe because their state never changes after construction.

One sentence to remember: final class, private final fields, no setters, defensive copies of mutable fields, validate in the constructor.

---

Share
Use ← → arrow keys to navigate