On this page
Before you start
This lesson uses ideas from these earlier lessons. Read them first if any idea is new to you.
01Why interviewers ask this
In the last lesson the canteen card allowed asha.balance = -500. In a round, the hidden tests try exactly such things. Examples are a payment of Rs 0, a payment larger than the balance, and a top-up that goes over a limit. If your class allows them, tests fail, and the interviewer sees that any code can break your data.
A weak answer leaves every field open and puts the checks in many places outside the class, or nowhere. A strong answer makes the class guard itself, so a wrong state cannot be made at all. This idea is called encapsulation: the data is hidden inside the class, and the class decides how it changes.
At SDE-1 level you should hide the fields and check input in each method. At SDE-2 level you should also make small values that can never change, list fixed choices by name, and give out copies of inner lists. Interviewers often ask: "How do you make sure this object is always valid?"
02The idea in plain words
Think of a vending machine. You cannot reach inside and move the coins. You use the slot and the buttons. The slot checks the coin, and the machine gives a snack only if the money is enough. The machine is always in a sensible state, because every change goes through its own checks.
A class can work the same way. First we need a name for the thing the checks protect. An invariant is a fact about an object that must be true after every method call. For the canteen card the invariants are: the balance is never below 0, and the balance is never above the limit of the card type.
Hide the fields
If outside code can write card.balance = -500, no invariant can hold. So we hide the fields. In Python, a name that starts with an underscore, such as _balance, means "internal: outside code should not touch this". This is a convention, a habit that programmers agree on. Python does not force it. The Python tutorial says that real private variables do not exist in Python. Java and C++ have a keyword, private, that the compiler forces. In a Python round, the underscore is the normal way to show that a field is internal.
To let outsiders read a value, use a read-only property. A line that starts with @ above a method asks Python to add a feature to it. The line @property makes the method read like a field: card.balance works, but card.balance = 5 raises an error. Do not add a way to set a field, unless a rule needs one.
Check in the constructor and in every method that changes something
The object must be valid right after it is made, and right after every method. So check inside the constructor and inside every method that changes a field. When a check fails, use raise ValueError("message"). This is a stop signal: the method stops at that line, nothing after it runs, and the caller gets an error with your message. ValueError is the built-in name for "the value is wrong".
To see the message without stopping the whole program, put the call in a try block and add except ValueError as error. If the call raises, Python jumps to the except line, and as error gives the error a name, so you can print its message.
Check the rule first, then change the field. Then a refused call leaves the object as it was.
Ask the object, do not take its data
Outside code should say what it wants: card.pay(30). It should not read the balance, work out the new value and write it back. That would skip every check in pay. Keep the public surface small: only the methods that outside code needs to call.
Return a copy, not the inner list
Suppose a method returns its inner list. The caller can then change the list, and the object has no say.
class Bad:
def __init__(self):
self._items = [1, 2]
def items(self):
return self._items
b = Bad()
b.items().clear()
print(b.items())clear() removes every item from a list. The code prints []: the caller emptied the inside of the object. A sort or a clear on a returned list changes the object. The fix is to return a new tuple, a list that cannot be changed. tuple(self._items) makes a new tuple from the list. The caller can read the tuple but cannot add, remove or replace items. If the items themselves could change, copy them too.
Value objects: frozen dataclasses
Some things have no life of their own. A receipt for Rs 30 never changes. Money amounts, ids and positions behave the same way: two of them with the same content are the same thing. These are value objects. They are best made so that they cannot change.
A dataclass is a class where Python writes the constructor, the text form and the equality check for you, from a list of fields. With frozen=True, Python blocks any change after the object is made. The object can then be used as a key of a dictionary (the name you look a value up by). The import line brings in code that Python already has.
from dataclasses import dataclass
@dataclass(frozen=True)
class Receipt:
card_id: str
amount: int
balance_after: intThe lines such as amount: int name the fields. The int is a note for readers; Python does not check it.
Enum: a fixed list of choices
If a value comes from a short fixed list, such as the type of a card, do not use free text. A text value "studnt" with a typo goes through without a warning. An enum is a class that lists the allowed choices as names: CardType.STUDENT. A typo such as CardType.STUDNT fails at once.
from enum import Enum
class CardType(Enum):
STUDENT = 200
STAFF = 500
@property
def limit(self):
return self.valueEach choice also carries a number: self.value is the number written after the name, 200 for STUDENT. It is the top balance in rupees (Rs). Assumption: a student card holds at most Rs 200, a staff card at most Rs 500.
In Java
Java does the same with keywords. A record is a frozen dataclass.
record Receipt(String cardId, int amount, int balanceAfter) {}
class CanteenCard {
private final String cardId;
private int balance;
CanteenCard(String cardId, int balance) { this.cardId = cardId; this.balance = balance; }
int getBalance() { return balance; }
Receipt pay(int amount) {
if (amount <= 0 || amount > balance) throw new IllegalArgumentException("refused");
balance -= amount;
return new Receipt(cardId, amount, balance);
}
}This box was compiled with Java 17, exactly as printed.
Want to check your understanding? Try explaining this idea in your own words. You can continue reading first, or ask the AI tutor a question. Sign up free to ask the tutor
03Pause and predict
Pause. Guess first.
A student card has Rs 100. The code runs
pay(30)and thenpay(100). What is the balance after each call? Is the balance unchanged after the second call?
Write your guess before you read on. The answer is in the worked example.
04Worked example
This is the canteen card, version 2. The invariants are: the balance is never below 0, and never above the limit of the card type. Money moves only through top_up and pay. To keep the class short, we leave out the holder name. Run with Python 3.11. The Receipt and CardType code is in the plain-words section above.
The first part of the class has the constructor and the readers. The text balance=0 means: if the caller gives no balance, use 0.
class CanteenCard:
def __init__(self, card_id, card_type, balance=0):
if balance < 0 or balance > card_type.limit:
raise ValueError("starting balance is outside the allowed range")
self._card_id = card_id
self._card_type = card_type
self._balance = balance
self._receipts = []
@property
def balance(self):
return self._balance
def receipts(self):
return tuple(self._receipts)balance is a property because it is a stored value. receipts() is a method because it builds a new tuple each time. The methods that change the balance go inside the same class, after receipts. The helper _check_amount starts with an underscore, because outsiders have no use for it.
def top_up(self, amount):
self._check_amount(amount)
if self._balance + amount > self._card_type.limit:
raise ValueError("balance would go above the card limit")
self._balance += amount
def pay(self, amount):
self._check_amount(amount)
if amount > self._balance:
raise ValueError("not enough balance")
self._balance -= amount
receipt = Receipt(self._card_id, amount, self._balance)
self._receipts.append(receipt)
return receipt
def _check_amount(self, amount):
if amount <= 0:
raise ValueError("amount must be more than 0")Now use it.
card = CanteenCard("C101", CardType.STUDENT, 100)
receipt = card.pay(30)
print(receipt, card.balance)
try:
card.pay(100)
except ValueError as error:
print("refused:", error)
print(card.balance)The output is Receipt(card_id='C101', amount=30, balance_after=70) 70, then refused: not enough balance, then 70. This answers the predict box. The first call gives 100 - 30 = Rs 70. The second asks for 100, which is more than 70, so it raises before it changes anything. The balance is unchanged: Rs 70.
Now the limit, and the blocked changes. Here type(error).__name__ is the name of the error's kind.
card.top_up(120)
print(card.balance)
try:
card.top_up(100)
except ValueError as error:
print(error, card.balance)
try:
card.balance = 500
except AttributeError as error:
print(type(error).__name__)
try:
receipt.amount = 5
except AttributeError as error:
print(type(error).__name__)
print(len(card.receipts()))It prints 190, then balance would go above the card limit 190, then AttributeError and FrozenInstanceError, then 1. The arithmetic: 70 + 120 = 190, which is within the Rs 200 limit. Then 190 + 100 = 290 is above 200, so the call is refused and the balance stays 190. Setting balance from outside fails because the property is read-only. Changing the frozen receipt fails too. The history holds one receipt, and receipts() returns a tuple, so the caller cannot empty it.
Last, a frozen receipt behaves as a value. In lesson 9 two cards with the same data were not equal. Two receipts with the same data are.
r1 = Receipt("C101", 30, 70)
r2 = Receipt("C101", 30, 70)
print(r1 == r2, r1 == receipt)
print({r1: "ok"})It prints True True, because receipt also has the fields C101, 30 and 70. The second line prints the dictionary {Receipt(card_id='C101', amount=30, balance_after=70): 'ok'}, so a frozen receipt works as a dictionary key.
05Trade-offs
Encapsulation solves wrong states. With the checks in one place, tests with bad amounts cannot break the card, and a rule change has one place to go. It costs more code: a property for each readable field, checks in each method, and a small public surface that outsiders may find strict. Python also does not force the underscore. I would hide fewer things when the object is only a group of values with no rule. Then a frozen dataclass is enough. I would add checks only for rules the problem states.
06Common misconceptions
"The underscore makes the field private." In Python it is only a convention. Code can still write card._balance = -500. In a round, say that it is a convention, and keep outside code away from it. Java's private is forced by the compiler.
"I validate in the constructor, so the object is safe." Only at the start. A later pay(-30) that adds money breaks the invariant unless pay checks too. Every method that changes the state must check.
"Returning the inner list is fine, because callers will not change it." One caller will, by mistake. A sort or a clear on the returned list changes the object. Return a tuple or a copy.
07How to say it in the interview
Say it like this
I decide which facts must always be true, hide the fields, and check them in the constructor and in every method that changes the state. Values like receipts are frozen, fixed choices are enums, and I return copies, so outside code cannot break the object.
08Check your understanding
- What is an invariant? Name the two invariants of the canteen card.
- A student card has Rs 150. The code runs
top_up(40)and thentop_up(20). What is the balance after each call? - A teammate adds
set_balance(self, value)"for the admin screen" and changesreceipts()toreturn self._receipts. What goes wrong, and what do you do?
09Answers
Show the answers
- An invariant is a fact about an object that must be true after every method call. The balance is never below 0, and never above the limit of the card type (Rs 200 for a student card).
- 150 + 40 = 190, which is within Rs 200. Then 190 + 20 = 210, which is above 200, so the call is refused and the balance stays Rs 190.
set_balancelets any code set a negative balance or a balance above the limit, so both invariants can break. Remove it and usetop_upandpay. If the admin screen really needs a correction, add one method that checks the same rules. Returningself._receiptslets a caller empty the history. Returntuple(self._receipts)again.
10Summary card: encapsulation
One-page summary
- Idea. An invariant is a fact that is always true. The class guards it, so a wrong state cannot be made.
- How. Hide fields with
_name. Read with@property. Check in the constructor and in every changing method.raise ValueErrorstops the method.- Tell, do not take. Call
card.pay(30). Do not read the balance and write it back.- Copies. Return a tuple or a copy, never the inner list.
- Values. Use
@dataclass(frozen=True)for receipts, ids and amounts. UseEnumfor fixed choices.- Trade-off. It solves wrong states. It costs more code. Python does not force
_name.- Trap. Checking only in the constructor.
11Sources
- The underscore as a convention, and the fact that real private variables do not exist in Python: Python tutorial, section 9.6 "Private Variables", https://docs.python.org/3/tutorial/classes.html (opens in a new tab) (checked 29 Oct 2026). A weak "internal use" signal: PEP 8, https://peps.python.org/pep-0008/ (opens in a new tab) (checked 29 Oct 2026).
frozen=TrueraisesFrozenInstanceErrorand makes the class hashable witheq=True: Python documentation,dataclasses, https://docs.python.org/3/library/dataclasses.html (opens in a new tab) (checked 29 Oct 2026).- Enum: https://docs.python.org/3/library/enum.html (opens in a new tab) (checked 29 Oct 2026).
- The canteen card and its limits are made up for teaching. The Python code was run with Python 3.11 and the Java box with Java 17.
Try what you just learned
Put this idea into practice with a short exercise or a relevant tool. Free to try. No sign-up needed.
More practice. This needs a free account.
What to learn next
Continue learning with the lessons that build on this one.
- Bad input and errors: check first, change last
- Clarify first: questions, assumptions and the rules list
- Composition or inheritance: has-a or is-a
Practise this idea
Have a question about this lesson?
Ask the AI tutor to explain a difficult idea, walk through an example or check your understanding. It uses the lesson to help you work through the topic.
A free account includes 10 AI tutor questions a day. The lesson remains free to read without an account.
All lessons in Module 2: Low-level design and machine coding
Prefer another topic? Browse all 169 lessons.