Back to DRY, KISS, YAGNI & Other Principles
Q4Question 4 of 4

How to create an immutable class in Java (final class, private final fields, no setters, defensive copies)

Key Concepts
  • Make the class final so no subclass can add mutable behaviour.
  • Make all fields private and final, assigned exactly once in the constructor.
  • Provide no setters or any mutating methods; return a new object for changes.
  • Defensively copy mutable fields both on the way in (constructor) and out (getters).
  • Immutable objects are inherently thread-safe, safe to cache, and make reliable map keys.
  • BigDecimal and String are already immutable; Java records simplify immutable class creation.

Definition

Make the class final, all fields private final and set once, no setters, and defensively copy anything mutable coming in or going out.
  • Immutable class – an instance whose state cannot change after construction.
  • Five rules – final class, private final fields, no setters, initialise in constructor, defensive copies of mutable fields.
  • Thread safety – since nothing can change, many threads can read concurrently without locks.
  • Safe caching – share one instance freely without fear of aliasing bugs.
  • Map keys – hash code and equality never change, so immutable objects are reliable map keys and set elements.

Think of a completed transaction receipt at National Bank of India. Once the receipt is printed showing Rajesh transferred fifty thousand rupees to Priya on a given date, nobody can go back and alter the amount or the date on that receipt. If you need a different amount, you print a brand-new receipt; you never edit the old one. An immutable class works the same way: once created, its contents are frozen forever.

Understanding the Concepts

What is an immutable class?

An immutable class is one whose instances cannot change state after construction. Java's own String, Integer, BigDecimal, and records are all immutable. The result is a thread-safe, side-effect-free object that is safe to share and cache freely.

What are the five rules to make a class immutable?

Each rule closes a specific loophole.

JAVA
package com.bank.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;

// Rule 1: final class – prevents subclass from adding mutable state
public final class TransferRecord {

    // Rule 2: private final fields – assigned once, never reassigned
    private final String transactionId;
    private final String fromAccount;
    private final String toAccount;
    private final BigDecimal amount;
    private final LocalDateTime timestamp;
    private final List<String> tags;

    // Rule 4: initialise all fields in the constructor
    public TransferRecord(String transactionId, String fromAccount,
                          String toAccount, BigDecimal amount,
                          LocalDateTime timestamp, List<String> tags) {
        this.transactionId = transactionId;
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
        this.timestamp = timestamp;
        // Rule 5: defensive copy of mutable field (List is mutable)
        this.tags = List.copyOf(tags);
    }

    // Rule 3: no setters – only getters
    public String getTransactionId() { return transactionId; }
    public String getFromAccount() { return fromAccount; }
    public String getToAccount() { return toAccount; }
    public BigDecimal getAmount() { return amount; }
    public LocalDateTime getTimestamp() { return timestamp; }

    // Rule 5: defensive copy on the way OUT
    public List<String> getTags() {
        return Collections.unmodifiableList(tags);
    }
}

Why must the class be final?

If the class can be extended, a subclass could override methods or add mutable state and pretend to be an instance of the immutable class, breaking the guarantee that callers rely on.

JAVA
// Without final, this is possible:
public class MutableTransferRecord extends TransferRecord {
    private BigDecimal mutableAmount; // mutable state added!

    // This breaks immutability for anyone holding a TransferRecord reference
}

// With final class, the compiler rejects the subclass

Why do you need defensive copies?

If a field is a mutable object like a List, storing the caller's reference directly is dangerous. The caller still holds that reference and can mutate the "immutable" object from outside.

JAVA
package com.bank.demo;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

public class DefensiveCopyDemo {
    public static void main(String[] args) {
        List<String> tags = new ArrayList<>();
        tags.add("NEFT");

        TransferRecord record = new TransferRecord(
            "TXN-001", "ACC-001", "ACC-002",
            new BigDecimal("50000"), LocalDateTime.now(), tags
        );

        // Without defensive copy, this would corrupt the record!
        tags.add("HACKED");

        // With List.copyOf in constructor, the record is safe
        System.out.println("Tags: " + record.getTags());
        // Output: Tags: [NEFT]
    }
}
TEXT
Tags: [NEFT]

Fields that are already immutable, like String, BigDecimal, and LocalDateTime, need no defensive copy.

How do you provide "change" methods on an immutable class?

Return a new instance instead of mutating the original. This is called a "with" or copy-style method.

JAVA
package com.bank.model;

import java.math.BigDecimal;

public final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    // "with" method: returns a NEW object
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money subtract(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.subtract(other.amount), this.currency);
    }

    public BigDecimal getAmount() { return amount; }
    public String getCurrency() { return currency; }

    @Override
    public String toString() {
        return amount + " " + currency;
    }
}
JAVA
package com.bank.demo;

import java.math.BigDecimal;

public class MoneyDemo {
    public static void main(String[] args) {
        Money balance = new Money(new BigDecimal("100000"), "INR");
        Money deposit = new Money(new BigDecimal("25000"), "INR");

        Money newBalance = balance.add(deposit); // new object
        System.out.println("Original: " + balance);
        System.out.println("After deposit: " + newBalance);
    }
}
TEXT
Original: 100000 INR
After deposit: 125000 INR

How do Java records simplify immutable classes?

A Java record automatically generates a final class with private final fields, a constructor, getters, equals, hashCode, and toString.

JAVA
package com.bank.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// Record: immutable by default
public record TransferEvent(
    String transactionId,
    String fromAccount,
    String toAccount,
    BigDecimal amount,
    LocalDateTime timestamp
) {}

For mutable fields like List, you still need a compact constructor with a defensive copy:

JAVA
import java.util.List;

public record TaggedTransfer(
    String id,
    BigDecimal amount,
    List<String> tags
) {
    public TaggedTransfer {
        tags = List.copyOf(tags); // defensive copy in compact constructor
    }
}

What are the benefits of immutability in banking?

  • Thread safety without locks – many threads can read concurrently because nothing changes.
  • Safe sharing and caching – hand one instance to notification, reporting, and reconciliation systems.
  • Reliable map keys – hash code and equality never change after construction.
  • No aliasing bugs – one part of the code cannot unexpectedly mutate state another part relies on.

What are the limitations?

Excessive copying can create garbage under heavy load. For very hot paths, you sometimes balance immutability against allocation cost. But for correctness and safety, especially with money, immutability is the strong default.

What should a senior engineer know?

  • BigDecimal for money is already immutable, which is one more reason to always use it.
  • In Spring, immutable DTOs and value objects reduce bugs in concurrent request handling.
  • Sealed classes (Java 17+) can restrict which classes implement an interface, combining well with immutability.
  • The Unmodifiable* wrappers from Collections do not copy: they wrap the original, so if someone holds the original reference they can still mutate. Use List.copyOf for a true copy.

Visual Overview

DIAGRAM
  Five Rules for Immutability
  ───────────────────────────

  Rule 1: final class
  ┌──────────────────────────────────┐
  │ public final class TransferRecord │ ◄── no subclass can add mutable state
  └──────────────────────────────────┘

  Rule 2: private final fields
  ┌──────────────────────────────────┐
  │ private final String txnId;      │
  │ private final BigDecimal amount; │ ◄── assigned once, never reassigned
  │ private final List<String> tags; │
  └──────────────────────────────────┘

  Rule 3: no setters
  Rule 4: initialise in constructor
  Rule 5: defensive copy

  Constructor (in):            Getter (out):
  tags = List.copyOf(tags)     return Collections.unmodifiableList(tags)
       ▲                             ▲
       │                             │
  Caller cannot mutate       Caller cannot mutate
  via original reference     via returned reference

Key Takeaway

An immutable class is one whose objects cannot change state after construction. Rajesh creates one by following five rules. He makes the class final so no subclass can add mutable behaviour. He makes all fields private and final so each is assigned exactly once. He provides no setters. He initialises every field in the constructor. And the subtle one: he defensively copies any mutable fields both on the way in and on the way out.

The defensive-copy rule is the part that trips people up. If a field is a mutable object like a List, storing the caller's reference directly is dangerous because the caller still holds that reference. In the constructor Rajesh copies it with List.copyOf, and in the getter he returns an unmodifiable view. Fields like String and BigDecimal are already immutable and need no copy.

At National Bank of India, Pankaj made the TransferRecord immutable because a completed transfer is shared across the notification, reporting, and reconciliation systems. If any of those could accidentally change the amount, the bank's books would become inconsistent. By freezing the record, Pankaj hands the same object to many systems concurrently with no locks. When a modified version is needed, a "with" method returns a brand-new record.

The benefits are thread safety without locks, safe sharing and caching, and reliable use as map keys. The one trade-off is that producing new objects for every change can create garbage under heavy load. But for correctness and safety, particularly with money, immutability is the strong default, and it is exactly how Java's own String, BigDecimal, and records are designed.

Remember one sentence: make the class final, all fields private final, no setters, and defensively copy anything mutable in and out.

---

Share
Use ← → arrow keys to navigate