Facebook Pixel

Encapsulation

Encapsulation is the practice of bundling state and the rules that govern it inside a single object, and refusing to let callers touch the state directly. The object becomes the sole authority over its own data: the only way to read or change it is through the methods the class exposes. Encapsulation covers private fields and, more importantly, deciding which invariants an object protects.

A bank account is the clearest domain to see this. The balance has constraints: it cannot go negative (in a standard checking account), deposits must be positive, and withdrawals must not exceed the available balance. If the balance is a public field, every piece of code that touches it must independently remember all of those rules — and eventually one caller will forget.

The cost of exposed state

When a field is public, the class loses control over the invariants it is supposed to maintain. Any caller can set the balance to a negative number, skip the validation on a withdrawal, or quietly overwrite the value mid-transaction. The class becomes a passive container, and the rules scatter across the codebase into whoever happens to need them.

The diagram below shows the encapsulated design: _balance is private and only reachable through the three guarded methods that enforce every invariant.

1# Bad: balance is public — nothing prevents a caller from bypassing the rules.
2class BankAccount:
3    def __init__(self, owner: str, initial_balance: float = 0.0) -> None:
4        self.owner = owner
5        self.balance = initial_balance   # any caller can set this directly
6
7account = BankAccount("Alice", 500.0)
8account.balance = -9999.0   # no error; the invariant is silently broken
9account.balance += 200.0    # no record kept, no validation
1from decimal import Decimal
2
3# Good: balance is private; the only way to mutate it is through guarded methods.
4class BankAccount:
5    def __init__(self, owner: str, initial_balance: Decimal = Decimal("0")) -> None:
6        if initial_balance < 0:
7            raise ValueError("initial balance cannot be negative")
8        self._owner = owner
9        self._balance = initial_balance
10        self._transactions: list[str] = []
11
12    @property
13    def balance(self) -> Decimal:
14        return self._balance
15
16    @property
17    def owner(self) -> str:
18        return self._owner
19
20    def deposit(self, amount: Decimal) -> None:
21        if amount <= 0:
22            raise ValueError(f"deposit amount must be positive, got {amount}")
23        self._balance += amount
24        self._transactions.append(f"deposit {amount}")
25
26    def withdraw(self, amount: Decimal) -> None:
27        if amount <= 0:
28            raise ValueError(f"withdrawal amount must be positive, got {amount}")
29        if amount > self._balance:
30            raise ValueError(
31                f"insufficient funds: balance {self._balance}, requested {amount}"
32            )
33        self._balance -= amount
34        self._transactions.append(f"withdrawal {amount}")
35
36    def statement(self) -> list[str]:
37        return list(self._transactions)   # return a copy; caller cannot mutate history

Tell, Don't Ask

There is a deeper principle at work here: Tell, Don't Ask. Instead of asking an object for its data and then deciding what to do with it externally, you tell the object what you need and let it make the decision using its own data. The bank account example illustrates this directly. In the bad version, the caller asks for the balance, checks it themselves, and then mutates it. In the good version, the caller tells the account to withdraw, and the account decides whether that is allowed.

This principle also applies to what you return. The statement() method above returns a copy of the transaction list, not the original. If it returned the internal list directly, a caller could add or remove entries and break the account's history without going through any guarded method. Returning copies or unmodifiable views is part of encapsulation — the object stays in control of its state even after returning data from it.

What to expose

The surface area of a class — its public methods — should be as small as it can get away with. Every additional public method or field is a contract with every caller. Once something is public, removing or changing it is a breaking change. Fields should almost never be public. Methods should be public only when they represent a genuine responsibility the class owns toward its collaborators.

In an interview, a useful habit is to start every class with all fields private, mark methods private by default, and only promote them to public when you can name the caller that needs them. This practice naturally keeps the class interface minimal and makes it obvious where behavior belongs.

Interview framing

Encapsulation questions come up in two forms. The first is explicit: "How would you design a BankAccount?" The second is implicit: you sketch a class, and the interviewer asks "what happens if a caller sets the balance directly?" Both are tests of the same judgment — do you understand that exposing state is a commitment to let callers do whatever they want with it?

A weak answer typically presents public fields paired with a separate validator or manager class. A stronger answer has private fields, a narrow public interface, and methods that enforce all invariants internally — making clear that the class, not its callers, owns each rule.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro