On this page
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:
- The idea: invariants, and methods that protect them.
- Immutability: what makes a class immutable in each language.
- How to apply it, step by step.
- 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:
- Make the class
final(so no subclass can add mutable state). - Make every field
private final. - Don't provide setters; "changing" it returns a new object.
- 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
- Start every field as
private(andfinalif it never needs to change). - Replace setters with meaningful operations:
deposit(),rename(),markShipped(). - Validate in the constructor and in every operation, so an invalid object can never exist.
- Never hand out a reference to a mutable internal collection - return a copy or an unmodifiable view.
- 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(...)andList.copyOf(...)create unmodifiable collections.- Domain-driven design calls immutable, value-compared types value objects.
Which tool for which job?
| You want to | Java | Python |
|---|---|---|
| Hide a field | private | a leading _ (a convention) |
| Read-only access to a value | an accessor method | @property without a setter |
| A small immutable value type | record | @dataclass(frozen=True) |
| Hand out a collection safely | List.copyOf(items) | tuple(items) |
| A read-only live view of a map | Collections.unmodifiableMap(m) | types.MappingProxyType(d) |
| "Change" an immutable object | a method returning a new one | dataclasses.replace(obj, ...) |
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| A getter returns the internal list | callers add, remove or clear it | return a copy or an unmodifiable view |
| Storing the caller's list in the constructor | the caller can still change it later | copy it on the way in |
| A setter for every field | anyone can put the object in an invalid state | meaningful operations that validate |
| Validating in a separate "validator" class | invalid objects can exist in between | validate in the constructor |
| A record or frozen dataclass holding a mutable list | the "immutable" object changes | copy 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.