Encapsulation in Python: A Wallet That Can’t Go Negative

Encapsulation in Python with a wallet that can't go negative: read-only properties, Decimal money, runnable code, real output and interview follow-ups.

OOP in Practice · Episode 1 of 70 · OOP Foundations · Beginner · Python 3.13.18

The problem

You’re building the wallet service for a small payments app, and this is encapsulation in Python at its most practical. Today the wallet balance is a public field. A refund script that reverses a card top-up is run twice by mistake, the balance goes to −70.00, and nothing complains. The bug only surfaces when someone reconciles the ledger by hand.

Your task: redesign the wallet so that no caller can put it into an invalid state, whether by accident or by a sloppy script.

Requirements

  • Users can top up, pay, and refund a previous top-up back to the card.
  • The balance can never go below zero.
  • Each top-up can be refunded at most once.
  • Amounts must be positive, numeric, and have at most 2 decimal places; floats are rejected.
  • Callers can read the balance and the transaction history but cannot write to either directly.

Constraints and assumptions

  • Single process, in memory, one currency, no database.
  • A refund returns the full top-up amount; partial refunds are out of scope.
  • Python standard library only.

What this tests: encapsulation, invariants, properties, validation at the boundary, defensive copies

Encapsulation in Python means the object owns its rules. Keep the balance and ledger in underscored attributes, expose the balance through a read-only @property, return the history as a tuple copy, and allow change only through methods like pay() and refund_top_up() that validate the amount and check the invariant before saving anything.

Try it yourself first — hint

Ask “who is allowed to change the balance?” The answer should be exactly one class. Then ask what a caller can still do with the objects you hand back to them.

Step 1: Identify the objects and responsibilities

Nouns: wallet, transaction, amount. Verbs: top up, pay, refund. Each rule needs one home.

Classes in the encapsulated wallet
Class / interface Responsibility Key methods
Wallet Owns the balance, the ledger and the refund record. Enforces every invariant. top_up(), pay(), refund_top_up(), balance, history
Transaction An immutable ledger entry: id, kind, amount, optional reference. (frozen dataclass, no methods)
to_amount() Turns raw input into a valid two-decimal Decimal, or refuses it. to_amount(value)
WalletError, InsufficientFunds Name the rule that was broken so callers can react. (exceptions)

Step 2: The naive approach and why it breaks

class Wallet:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0.0          # public: anyone can write it
        self.history = []           # public list: anyone can append/clear


wallet = Wallet("asha")
wallet.balance += 50.0
wallet.history.append(("top_up", 50.0))
wallet.balance -= 20.0
wallet.history.append(("pay", 20.0))

# A refund script reverses the 50.00 top-up... and is accidentally run twice.
for _ in range(2):
    wallet.balance -= 50.0
    wallet.history.append(("refund", 50.0))

print(wallet.balance)               # -70.0 and no error anywhere
wallet.history.clear()              # the audit trail is gone too
print(wallet.history)               # []
print(0.1 + 0.2)                    # 0.30000000000000004 — floats for money

Running it prints -70.0, then [], then 0.30000000000000004. Every line is legal Python, which is the problem.

  • No single owner of the rule. Every script must remember “never below zero”. One that forgets breaks the wallet silently.
  • Nothing stops a double refund. The wallet has no memory of which top-ups were already reversed.
  • The audit trail is writable. history.clear() erases the live list.
  • Floats for money. Binary floats can’t represent 0.1 exactly, so totals drift. The decimal module docs explain why Decimal avoids this.

Step 3: The design

+-----------------------------------------+        +----------------------+
|                 Wallet                  |        | Transaction (frozen) |
+-----------------------------------------+ 1    * +----------------------+
| - _owner: str                           |<>----->| txn_id: str          |
| - _balance: Decimal                     |        | kind: str            |
| - _ledger: list[Transaction]            |        | amount: Decimal      |
| - _refunded: set[str]                   |        | ref: str | None      |
+-----------------------------------------+        +----------------------+
| + balance  (read-only property)         |
| + history  (returns tuple copy)         |        +----------------------+
| + top_up(amount) -> str                 |------> | to_amount(value)     |
| + pay(amount) -> str                    | uses   | -> Decimal | raises  |
| + refund_top_up(txn_id) -> str          |        +----------------------+
| - _record(kind, amount, sign) -> str    |
| - _check_invariant()                    |        WalletError
+-----------------------------------------+          ^-- InsufficientFunds

One principle drives the design: data and the rules that guard it live together. Wikipedia defines encapsulation as bundling data with the methods that operate on it. Martin Fowler’s Tell-Don’t-Ask note says the same from the caller’s side: tell the wallet to pay; don’t read the balance and decide for it.

Python has no private keyword; the Python tutorial says truly private instance variables don’t exist. PEP 8 recommends one leading underscore for non-public attributes. So the wallet uses three tools that do work: the underscore convention, a property with no setter, and returning copies.

Every state change goes through one private method, _record(), which makes the invariant checkable. The next episode, abstraction in OOP, hides how something works behind an interface.

Step 4: The encapsulation in Python solution

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal, InvalidOperation


class WalletError(Exception):
    """Base class for every rule the wallet refuses to break."""


class InsufficientFunds(WalletError):
    pass


@dataclass(frozen=True)
class Transaction:
    txn_id: str
    kind: str          # "top_up", "payment" or "refund"
    amount: Decimal
    ref: str | None = None


def to_amount(value: Decimal | str | int) -> Decimal:
    """Validate an amount once, at the boundary. Floats are rejected on purpose."""
    if isinstance(value, (float, bool)):
        raise WalletError(f"use Decimal or str for money, got {type(value).__name__}")
    try:
        amount = Decimal(value)
    except InvalidOperation:
        raise WalletError(f"not a number: {value!r}") from None
    if not amount.is_finite() or amount <= 0:
        raise WalletError(f"amount must be positive, got {value}")
    if amount.as_tuple().exponent < -2:
        raise WalletError(f"at most 2 decimal places, got {value}")
    return amount.quantize(Decimal("0.01"))


class Wallet:
    """Invariant: balance >= 0 and balance == sum of the ledger."""

    def __init__(self, owner: str) -> None:
        self._owner = owner
        self._balance = Decimal("0.00")
        self._ledger: list[Transaction] = []
        self._refunded: set[str] = set()

    @property
    def owner(self) -> str:
        return self._owner

    @property
    def balance(self) -> Decimal:          # read-only: no setter defined
        return self._balance

    @property
    def history(self) -> tuple[Transaction, ...]:
        return tuple(self._ledger)         # a copy, never the live list

    def top_up(self, amount: Decimal | str | int) -> str:
        return self._record("top_up", to_amount(amount), +1)

    def pay(self, amount: Decimal | str | int) -> str:
        value = to_amount(amount)
        if value > self._balance:
            raise InsufficientFunds(f"pay {value}: balance is only {self._balance}")
        return self._record("payment", value, -1)

    def refund_top_up(self, txn_id: str) -> str:
        """Send a top-up back to the card. Each top-up can be refunded once."""
        original = next((t for t in self._ledger if t.txn_id == txn_id), None)
        if original is None or original.kind != "top_up":
            raise WalletError(f"{txn_id} is not a top-up on this wallet")
        if txn_id in self._refunded:
            raise WalletError(f"{txn_id} was already refunded")
        if original.amount > self._balance:
            raise InsufficientFunds(f"refund {original.amount}: balance is only {self._balance}")
        self._refunded.add(txn_id)
        return self._record("refund", original.amount, -1, ref=txn_id)

    def _record(self, kind: str, amount: Decimal, sign: int, ref: str | None = None) -> str:
        txn = Transaction(f"T{len(self._ledger) + 1}", kind, amount, ref)
        self._ledger.append(txn)
        self._balance += sign * amount
        self._check_invariant()
        return txn.txn_id

    def _check_invariant(self) -> None:
        signed = sum((t.amount if t.kind == "top_up" else -t.amount for t in self._ledger),
                     Decimal("0.00"))
        assert self._balance >= 0 and self._balance == signed, "wallet invariant broken"


def attempt(label: str, action) -> None:
    try:
        result = action()
        print(f"{label:<28} ok      -> {result}")
    except (WalletError, AttributeError, TypeError) as err:
        print(f"{label:<28} refused -> {type(err).__name__}: {err}")


if __name__ == "__main__":
    w = Wallet("asha")
    first = w.top_up("50.00")
    attempt("pay 20.00", lambda: w.pay("20.00"))
    attempt("refund T1 (balance 30)", lambda: w.refund_top_up(first))
    attempt("top up 0.10 + 0.20", lambda: (w.top_up("0.10"), w.top_up("0.20")))
    attempt("refund T3", lambda: w.refund_top_up("T3"))
    attempt("refund T3 again", lambda: w.refund_top_up("T3"))
    attempt("pay 100", lambda: w.pay(100))
    attempt("top up -5", lambda: w.top_up("-5"))
    attempt("top up 9.999", lambda: w.top_up("9.999"))
    attempt("top up 'ten'", lambda: w.top_up("ten"))
    attempt("top up 0.1 (float)", lambda: w.top_up(0.1))
    attempt("w.balance = 1000", lambda: setattr(w, "balance", Decimal("1000")))
    attempt("w.history.append(...)", lambda: w.history.append("fake"))
    print(f"\nbalance: {w.balance}")
    for t in w.history:
        print(f"  {t.txn_id} {t.kind:<8} {t.amount:>6} {t.ref or ''}".rstrip())
    assert w.balance == Decimal("30.20") and len(w.history) == 5
  • Validate once, at the boundary. to_amount() rejects floats, booleans, non-numbers, non-positive values and a third decimal place. Inside the wallet, every amount is a clean Decimal.
  • Read-only balance. balance is a property with no setter; the descriptor guide shows assignment then raises AttributeError.
  • No leaking the live list. history returns a tuple copy, so callers can read the ledger but not change it.
  • Immutable entries. Transaction is a frozen dataclass, so an entry can’t be edited after it’s written.
  • Refunds remember. _refunded records reversed top-ups, so a double-run script fails loudly.
  • Check, then commit. Public methods check their rule before _record(), which re-checks the whole invariant.

Step 5: Run it and check the output

pay 20.00                    ok      -> T2
refund T1 (balance 30)       refused -> InsufficientFunds: refund 50.00: balance is only 30.00
top up 0.10 + 0.20           ok      -> ('T3', 'T4')
refund T3                    ok      -> T5
refund T3 again              refused -> WalletError: T3 was already refunded
pay 100                      refused -> InsufficientFunds: pay 100.00: balance is only 30.20
top up -5                    refused -> WalletError: amount must be positive, got -5
top up 9.999                 refused -> WalletError: at most 2 decimal places, got 9.999
top up 'ten'                 refused -> WalletError: not a number: 'ten'
top up 0.1 (float)           refused -> WalletError: use Decimal or str for money, got float
w.balance = 1000             refused -> AttributeError: property 'balance' of 'Wallet' object has no setter
w.history.append(...)        refused -> AttributeError: 'tuple' object has no attribute 'append'

balance: 30.20
  T1 top_up    50.00
  T2 payment   20.00
  T3 top_up     0.10
  T4 top_up     0.20
  T5 refund     0.10 T3

Tested: Python 3.13.18 — python3 main.py, 2026-11-12. Output above is copied from that run.

The 50.00 refund is refused while only 30.00 is left. The second refund of T3 is refused, which is the bug from the problem card. Direct writes fail, and 0.10 + 0.20 lands as exactly 0.30.

Why this design works

  • Encapsulation: only Wallet writes _balance, so “never below zero” is enforced in one place instead of in every script.
  • Invariants you can state: “balance ≥ 0 and balance equals the ledger sum” is written as code in _check_invariant(), not as a comment someone hopes is true.
  • Single responsibility: to_amount() only parses money, Transaction only records, Wallet only applies rules.
  • Stable API: wallet.balance reads the same as a plain attribute or a property, so rules can be added later.
  • Fail loudly: each rule raises a named exception. A refund script that runs twice now stops on the second run.

Trade-offs, edge cases and when not to use it

Choice Benefit Cost or limit
Underscore attributes Clear signal, zero runtime cost, idiomatic Not enforced; w._balance = 1 still works. PEP 8 notes no attribute is really private.
Read-only @property Blocks accidental assignment, keeps attribute syntax A determined caller can still write the underscored field.
tuple(self._ledger) copy Callers can’t mutate the ledger O(n) copy on every read; fine for small histories.
assert in _check_invariant() Catches bugs during development and tests The assert statement emits no code when Python runs with optimisation (-O). Raise an exception if the check must always run.

When is it overkill? A data holder with no rules, like a config record, is clearer as a plain dataclass. Encapsulate when bad input could break an invariant.

Interview follow-up questions

How would you make this wallet safe for concurrent requests?

Two threads could both read 30.00 and both approve a 20.00 payment. Make check-then-commit atomic: add a threading.Lock and hold it across the check and _record(). Because every change already goes through three public methods, the lock lives in one place. Across processes, move the rule into the database, for example a conditional update or a row lock.

How would you test the invariant?

Write one unit test per refusal path: overdraw, double refund, bad amounts and direct assignment. Then add a seeded random sequence test that asserts the balance equals the ledger sum and never drops below zero after every step. Tests read history instead of private fields, so they survive internal refactors.

How would you support partial refunds or multiple currencies?

For partial refunds, replace the _refunded set with a map of top-up id to amount refunded so far, and refuse anything beyond the original. For currencies, add a Money value object that pairs a Decimal with a currency code and refuses to mix currencies. Both changes stay inside the wallet; callers keep the same three methods.

The same solution in Java

Show the Java version
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Wallet w = new Wallet("asha");
        String first = w.topUp("50.00");
        attempt("pay 20.00", () -> w.pay("20.00"));
        attempt("refund T1 (balance 30)", () -> w.refundTopUp(first));
        attempt("top up 0.10", () -> w.topUp("0.10"));
        attempt("refund T3", () -> w.refundTopUp("T3"));
        attempt("refund T3 again", () -> w.refundTopUp("T3"));
        attempt("top up 9.999", () -> w.topUp("9.999"));
        attempt("history().clear()", () -> { w.history().clear(); return "cleared"; });
        System.out.println("\nbalance: " + w.balance());
        w.history().forEach(t -> System.out.println("  " + t));
    }

    interface Action { String run(); }

    static void attempt(String label, Action action) {
        try {
            System.out.printf("%-24s ok      -> %s%n", label, action.run());
        } catch (WalletException | UnsupportedOperationException e) {
            System.out.printf("%-24s refused -> %s: %s%n", label, e.getClass().getSimpleName(),
                    e.getMessage() == null ? "(no message)" : e.getMessage());
        }
    }
}

class WalletException extends RuntimeException {
    WalletException(String message) { super(message); }
}

record Transaction(String id, String kind, BigDecimal amount, String ref) {}

final class Wallet {
    private final String owner;
    private BigDecimal balance = new BigDecimal("0.00");   // private: only Wallet writes it
    private final List<Transaction> ledger = new ArrayList<>();
    private final Set<String> refunded = new HashSet<>();

    Wallet(String owner) { this.owner = owner; }

    String owner() { return owner; }
    BigDecimal balance() { return balance; }                 // BigDecimal is immutable
    List<Transaction> history() { return List.copyOf(ledger); }

    String topUp(String amount) { return record("top_up", toAmount(amount), null); }

    String pay(String amount) {
        BigDecimal value = toAmount(amount);
        if (value.compareTo(balance) > 0) throw new WalletException("balance is only " + balance);
        return record("payment", value.negate(), null);
    }

    String refundTopUp(String id) {
        Transaction t = ledger.stream().filter(x -> x.id().equals(id) && x.kind().equals("top_up"))
                .findFirst().orElseThrow(() -> new WalletException(id + " is not a top-up"));
        if (refunded.contains(id)) throw new WalletException(id + " was already refunded");
        if (t.amount().compareTo(balance) > 0) throw new WalletException("balance is only " + balance);
        refunded.add(id);
        return record("refund", t.amount().negate(), id);
    }

    private static BigDecimal toAmount(String raw) {
        BigDecimal v = new BigDecimal(raw);
        if (v.signum() <= 0) throw new WalletException("amount must be positive: " + raw);
        if (v.scale() > 2) throw new WalletException("at most 2 decimal places: " + raw);
        return v.setScale(2, RoundingMode.UNNECESSARY);
    }

    private String record(String kind, BigDecimal signed, String ref) {
        String id = "T" + (ledger.size() + 1);
        ledger.add(new Transaction(id, kind, signed.abs(), ref));
        balance = balance.add(signed);
        return id;
    }
}
pay 20.00                ok      -> T2
refund T1 (balance 30)   refused -> WalletException: balance is only 30.00
top up 0.10              ok      -> T3
refund T3                ok      -> T4
refund T3 again          refused -> WalletException: T3 was already refunded
top up 9.999             refused -> WalletException: at most 2 decimal places: 9.999
history().clear()        refused -> UnsupportedOperationException: (no message)

balance: 30.00
  Transaction[id=T1, kind=top_up, amount=50.00, ref=null]
  Transaction[id=T2, kind=payment, amount=20.00, ref=null]
  Transaction[id=T3, kind=top_up, amount=0.10, ref=null]
  Transaction[id=T4, kind=refund, amount=0.10, ref=T3]

Tested: OpenJDK 21.0.12.1 — java Main.java, 2026-11-12.

Java enforces what Python only signals: a private member is visible only in its own class. List.copyOf returns an unmodifiable list, so clear() throws. BigDecimal is immutable, and its equals also compares scale, so the code uses compareTo.

Practice: extend it yourself

  1. Daily spending limit. Take a limit in the constructor. Expected behaviour: with a 25.00 limit, pay("20.00") succeeds and a following pay("10.00") raises WalletError; the balance is unchanged.
  2. Freeze the wallet. Add freeze() and unfreeze(). Expected behaviour: while frozen, all three operations raise WalletError; reads still work.
  3. Partial refunds. Let refund_top_up(txn_id, amount) refund part of a top-up. Expected behaviour: refunding 30.00 and then 20.00 of a 50.00 top-up succeeds; a further 0.01 is refused as exceeding the original.
  • Encapsulation in Python means one class owns each rule, and every change to its state goes through methods that enforce that rule.
  • Python has no private keyword: a single leading underscore marks an attribute as non-public by convention, as PEP 8 recommends.
  • A @property with no setter makes an attribute read-only; assigning to it raises AttributeError.
  • Returning a live list from a getter breaks encapsulation; return a tuple or other copy instead.
  • Use Decimal, constructed from strings, for money; binary floats cannot represent values like 0.1 exactly.

Frequently Asked Questions

Does Python have private variables?

Not strictly. PEP 8 says no attribute is really private in Python. A single leading underscore marks a name as non-public by convention. A double underscore triggers name mangling, but code can still reach the attribute through the mangled name.

What is the difference between _balance and __balance in Python?

A single underscore (_balance) is a signal to other developers: this is an implementation detail, do not touch it. A double underscore (__balance) is rewritten by the compiler to _ClassName__balance. That mangling exists to stop subclasses from clashing with your names by accident, not to lock anyone out.

Should I write get_balance() and set_balance() methods in Python?

Usually not. Start with a plain attribute and switch to @property when you need a rule; callers keep writing wallet.balance. For a value that should only change through business operations, give it a getter, no setter, and named methods such as pay().

How do you make an attribute read-only in Python?

Store the value under an underscored name and expose it with @property and no setter; assignment then raises AttributeError. For objects that should never change, such as ledger entries, use a frozen dataclass.

Is encapsulation in Python a security feature?

No. Encapsulation in Python protects against mistakes, not attackers. Any code in the same process can change underscored attributes. Its job is to give each rule one home, so a buggy script cannot break an invariant through the normal API.

References