OOP Fundamentals · Core OOP Concepts

Encapsulation & Immutability

Protect an object's invariants behind its methods, avoid leaking internal state, and design value objects that never change once created.

  • 5 min read
  • 9 practice questions
  • 2 graded problems
On this page
  1. Description
  2. The idea
  3. Immutability
  4. How to apply it, step by step
  5. Where you'll see it
  6. Which tool for which job?
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

Encapsulation means an object owns its data and is the only one allowed to change it, so the rules about that data can't be broken from outside. Immutability is its strongest form: an object that never changes after it's built. Together they're what make objects trustworthy - safe to pass around, to share between threads and to use as map keys. This skill covers both, in Java and Python.

Here's what goes wrong without them:

Java

class BankAccount {
    public double balance;
    public List<String> history = new ArrayList<>();
}

account.balance = -5000;          // nothing stops this
account.history.clear();          // or this

Python

class BankAccount:
    def __init__(self):
        self.balance = 0.0
        self.history = []


account.balance = -5000          # nothing stops this
account.history.clear()          # or this

With public fields, every piece of code in the system can break the account's rules. When a balance goes negative, you have to search the whole codebase to find out who did it.

We'll go from simple to more technical:

  1. The idea: invariants, and methods that protect them.
  2. Immutability: what makes a class immutable in each language.
  3. How to apply it, step by step.
  4. Where you'll see it - and the leaks that quietly undo it.

The idea

Encapsulation means an object owns its data and only lets the outside world change it through methods that keep its invariants - the rules that must always be true (a balance never below the overdraft limit, a date range whose start is before its end).

Java

final class BankAccount {
    private long balanceCents;
    private final List<String> history = new ArrayList<>();

    void deposit(long cents) {
        if (cents <= 0) throw new IllegalArgumentException("deposit must be positive");
        balanceCents += cents;
        history.add("deposit " + cents);
    }

    void withdraw(long cents) {
        if (cents <= 0) throw new IllegalArgumentException("withdrawal must be positive");
        if (cents > balanceCents) throw new IllegalStateException("insufficient funds");
        balanceCents -= cents;
        history.add("withdraw " + cents);
    }

    long balance() { return balanceCents; }
    List<String> history() { return List.copyOf(history); }   // a copy, not the real list
}

Python

class BankAccount:
    def __init__(self):
        self._balance_cents = 0              # "_": internal by convention - Python has no private
        self._history = []

    def deposit(self, cents: int) -> None:
        if cents <= 0:
            raise ValueError("deposit must be positive")
        self._balance_cents += cents
        self._history.append(f"deposit {cents}")

    def withdraw(self, cents: int) -> None:
        if cents <= 0:
            raise ValueError("withdrawal must be positive")
        if cents > self._balance_cents:
            raise RuntimeError("insufficient funds")
        self._balance_cents -= cents
        self._history.append(f"withdraw {cents}")

    @property
    def balance(self) -> int:                # read-only: `account.balance = 5` raises AttributeError
        return self._balance_cents

    def history(self) -> tuple[str, ...]:
        return tuple(self._history)          # a snapshot, not the real list

Every change to the balance goes through one place, so the rules can't be bypassed.

Immutability

The strongest form of encapsulation is an object that never changes after construction. To make a class immutable:

  1. Make the class final (so no subclass can add mutable state).
  2. Make every field private final.
  3. Don't provide setters; "changing" it returns a new object.
  4. Defensively copy mutable inputs and outputs (lists, arrays, dates).

Python has no final or private, so immutability is assembled from other parts: a frozen dataclass (or __slots__ plus read-only properties), tuples and frozenset in place of lists and sets, and the same defensive copies. A frozen dataclass that holds a list is still mutable through that list.

Java

record Money(long cents, String currency) {
    Money {
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("currency required");
    }
    Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
        return new Money(cents + other.cents, currency);   // new object; this one never changes
    }
}

Python

from dataclasses import dataclass


@dataclass(frozen=True)
class Money:
    cents: int
    currency: str

    def __post_init__(self):
        if not self.currency or self.currency.isspace():
            raise ValueError("currency required")

    def plus(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("currency mismatch")
        return Money(self.cents + other.cents, self.currency)   # new object; this one never changes

Java records give you the final fields, constructor, accessors, equals and hashCode for free. Python's @dataclass(frozen=True) does the same - __init__, __eq__, __hash__ and a readable repr - and assigning to a field raises FrozenInstanceError.

How to apply it, step by step

  1. Start every field as private (and final if it never needs to change).
  2. Replace setters with meaningful operations: deposit(), rename(), markShipped().
  3. Validate in the constructor and in every operation, so an invalid object can never exist.
  4. Never hand out a reference to a mutable internal collection - return a copy or an unmodifiable view.
  5. For small "value" types (money, dates, coordinates), make them immutable.

Where you'll see it

  • String, Integer, LocalDate, BigDecimal - all immutable, which is why they're safe to share and to use as map keys. (Python: str, int, tuple, frozenset, datetime.date, Decimal.)
  • List.of(...), Map.of(...) and List.copyOf(...) create unmodifiable collections.
  • Domain-driven design calls immutable, value-compared types value objects.

Which tool for which job?

You want toJavaPython
Hide a fieldprivatea leading _ (a convention)
Read-only access to a valuean accessor method@property without a setter
A small immutable value typerecord@dataclass(frozen=True)
Hand out a collection safelyList.copyOf(items)tuple(items)
A read-only live view of a mapCollections.unmodifiableMap(m)types.MappingProxyType(d)
"Change" an immutable objecta method returning a new onedataclasses.replace(obj, ...)

Common mistakes

MistakeWhat happensDo this instead
A getter returns the internal listcallers add, remove or clear itreturn a copy or an unmodifiable view
Storing the caller's list in the constructorthe caller can still change it latercopy it on the way in
A setter for every fieldanyone can put the object in an invalid statemeaningful operations that validate
Validating in a separate "validator" classinvalid objects can exist in betweenvalidate in the constructor
A record or frozen dataclass holding a mutable listthe "immutable" object changescopy into an immutable collection

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. A getter returns this.items (an ArrayList). What can a caller do that you didn't intend?

Show answer

Modify your internal list directly - add, remove or clear items - bypassing every rule your class enforces. Return List.copyOf(items) (or an unmodifiable view) instead.

2. What four things make a Java class immutable?

Show answer

final class, private final fields, no setters (operations return new objects), and defensive copies of any mutable inputs and outputs.

3. Why are immutable objects safe to use as HashMap keys?

Show answer

Their hashCode can never change after they're inserted, so the map can always find them again.

Key takeaways

  • Encapsulation = the object protects its own invariants; nobody else can break them.
  • Replace setters with meaningful, validating operations.
  • Never leak or store references to mutable internals - copy them.
  • Immutable value objects (final, private final, no setters, records) are safe to share and to hash.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You want toJavaPython
Hide stateprivate_name (convention)
Read-only valueaccessor method@property
Immutable value typerecord@dataclass(frozen=True)
Stable copy of a collectionList.copyOftuple(...)
Live read-only viewCollections.unmodifiableListtypes.MappingProxyType
A changed copya withX(...) methoddataclasses.replace

Snippets worth knowing by heart

Java

record Team(String name, List<String> members) {
    Team {
        if (name == null || name.isBlank()) throw new IllegalArgumentException("name required");
        members = List.copyOf(members);
    }
}

Python

@dataclass(frozen=True)
class Team:
    name: str
    members: tuple[str, ...]

    def __post_init__(self):
        if not self.name:
            raise ValueError("name required")
        object.__setattr__(self, "members", tuple(self.members))

The traps

  • Returning the internal list - callers can change it.
  • Keeping the caller's list - they can change it later.
  • A setter for every field - no invariant survives.
  • record/frozen dataclass with a list field - shallowly immutable only.

Staff-level points, one line each

  • Shallow vs deep: copy into List.copyOf/tuple at construction; a frozen dataclass with a list isn't hashable.
  • A view sees later changes; a snapshot doesn't; List.copyOf of an immutable list returns it as is.
  • Reflection can write a normal final field; records refuse (IllegalAccessException).
  • Python's frozen check is bypassed by object.__setattr__ - convention, not enforcement.
  • Java's final-field guarantee makes immutable objects safe to share - unless this escapes the constructor.
  • Swapping between immutable versions still needs safe publication (volatile, AtomicReference).
  • Frozen dataclasses construct about 2x slower than regular ones in CPython - irrelevant except in hot loops.
  • Large, changing data: builders or persistent data structures, not copy-on-every-change.

Say it in an interview

  • "Every change goes through deposit and withdraw, which check the rules."
  • "The record copies its list in the compact constructor, so it's immutable all the way down."
  • "I return a snapshot, not a view, because callers shouldn't see it shift."
  • "Immutable value objects can be shared across threads without locks."

Practice

This skill comes with 9 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Encapsulation & Immutability free

An Immutable Money Type That Never Loses a Cent

MediumProCoding problemJava · Python

Money is an immutable value object (a record): every operation returns a new Money and leaves the original untouched. Amounts are stored as whole cents so no floating-point rounding can creep in.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Encapsulation & Immutability free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime