Encapsulation Checkpoint and Interactive Lab
20 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)This hands-on checkpoint synthesizes everything you have learned about encapsulation, private conventions, and properties. You will construct a production-ready BankAccount model that enforces business rules and audits transactions.
The BankAccount Domain Rules
Your BankAccount class must satisfy four strict business requirements:
- Owner validation: Name must be a non-empty string; strip leading/trailing spaces.
- Balance protection: Initial balance must be non-negative; balance cannot be directly mutated without validation.
- Deposit method: Only positive amounts accepted; increment balance.
- Withdraw method: Amount must be positive and cannot exceed current balance; decrement balance.
Guarding Invariants
Class invariant is a rule that every object of the class must always satisfy, such as balance >= 0. Methods that change state must check it before they save the change.
A bank account balance can never drop below zero. By encapsulating _balance behind property getters and dedicated transaction methods, callers cannot bypass business logic.
class BankAccount: def __init__(self, owner, initial_balance=0.0): self.owner = owner if initial_balance < 0: raise ValueError("Initial balance cannot be negative") self._balance = float(initial_balance) self._transactions = []Example 1 · Withdraw that cannot overdraw
class Account: def __init__(self, owner, balance=0): self.owner = owner self._balance = balance
@property def balance(self): return self._balance
def withdraw(self, amount): if amount > self._balance: raise ValueError("insufficient funds") self._balance -= amount
acct = Account("Neha", 500)acct.withdraw(200)try: acct.withdraw(900)except ValueError as e: print(e)print(acct.balance)insufficient funds 300
What this code does
- The check runs before the change, so a failed withdrawal leaves the balance as it was.
balancehas no setter, so callers must usewithdraw.
Notice _transactions is protected, keeping the transaction history safe from external corruption.
Audit Log Recording
Every time money moves, append an immutable record to self._transactions.
def deposit(self, amount): if amount <= 0: raise ValueError("Deposit amount must be positive") self._balance += amount self._transactions.append(f"+{amount:.2f}")Example 2 · Read-only view of the history
class Account: def __init__(self): self._history = []
def deposit(self, amount): self._history.append(amount)
@property def history(self): return tuple(self._history)
acct = Account()acct.deposit(100)acct.deposit(50)view = acct.historyprint(view)try: view.append(999)except AttributeError: print("history is read-only")print(len(acct.history))(100, 50) history is read-only 2
What this code does
- Returning a tuple gives the caller a copy that cannot be changed.
- Returning
self._historyitself would let outside code edit the audit log.
Example 3 · Statement built from the log
class Account: def __init__(self): self._log = []
def deposit(self, amount): self._log.append(("deposit", amount))
def withdraw(self, amount): self._log.append(("withdraw", amount))
def statement(self): return [f"{kind}: {amount}" for kind, amount in self._log]
acct = Account()acct.deposit(300)acct.withdraw(120)for line in acct.statement(): print(line)deposit: 300 withdraw: 120
What this code does
- The log is private state;
statementis the public way to read it. - The class can change how it stores entries without breaking callers.
This pattern models real-world double-entry accounting engines.
Interactive Verification
Test both valid execution paths and edge cases (attempting negative deposits or overdrafts) to ensure your model behaves reliably under all conditions.
- Verify normal operations produce correct balances.
- Verify invalid operations raise expected ValueError exceptions.
- Confirm internal state remains untouched when an exception is raised.
Next step
Check your understanding
12 quick questions