I'll Be Backend
Principles · Python

Encapsulation: public fields to guarded state

Make one class own its data, so every change follows the same rules.

13 steps4 chapters~5 min at 1.25×#oop#encapsulation#private fields#getters and setters
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–3

Refactor

Steps 4–9

Payoff

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

class GiftCard:
    def __init__(self, code: str, balance: int) -> None:
        self.code = code
        self.balance = balance


class Checkout:
    def pay(self, card: GiftCard, amount: int) -> None:
        if card.balance >= amount:
            card.balance -= amount


class Refunds:
    def refund(self, card: GiftCard, amount: int) -> None:
        card.balance += amount


class Support:
    def cancel(self, card: GiftCard) -> None:
        card.balance = 0


card = GiftCard("GC-7", 50)
Checkout().pay(card, 30)
Refunds().refund(card, 10)
Support().cancel(card)
print(card.balance)

After

class GiftCard:
    def __init__(self, code: str, balance: int) -> None:
        self.code = code
        self._balance = balance
        self._history: list[str] = []

    @property
    def balance(self) -> int:
        return self._balance

    @property
    def history(self) -> tuple[str, ...]:
        return tuple(self._history)

    def charge(self, amount: int) -> bool:
        if self._balance >= amount:
            self._balance -= amount
            self._history.append(f"paid {amount}")
            return True
        return False

    def credit(self, amount: int) -> None:
        self._balance += amount
        self._history.append(f"refunded {amount}")

    def cancel(self) -> None:
        self._history.append(f"cancelled {self._balance}")
        self._balance = 0


class Checkout:
    def pay(self, card: GiftCard, amount: int) -> None:
        card.charge(amount)


class Refunds:
    def refund(self, card: GiftCard, amount: int) -> None:
        card.credit(amount)


class Support:
    def cancel(self, card: GiftCard) -> None:
        card.cancel()


card = GiftCard("GC-7", 50)
Checkout().pay(card, 30)
Refunds().refund(card, 10)
Support().cancel(card)
print(card.balance)
print(", ".join(card.history))

Transcript, step by step

Problem

Step 1: A shop's gift cards Before

A gift card here holds a code and a balance, and both are plain public attributes. Checkout takes money off when there's enough, Refunds puts money back, and Support cancels a card by setting the balance to zero. The script at the bottom starts the card at 50, pays 30, gets 10 back, and cancels it, so it prints 0.

Step 2: Finance wants a history Code smell

Finance now needs a history of every balance change, for audits. The quick way is a public history list on the card, plus a line in pay and a line in refund that record each change. The script then prints the balance, followed by the history.

Step 3: Every class writes the balance Code smell

A public attribute lets any class change the balance, so the rule for changing it is spread over every class that writes it. Checkout writes the balance here, Refunds here, and Support here. Support's cancel takes 30 off the card, and the history never hears of it, so the audit shows 30 left on a card that holds 0. And the funds check lives in Checkout only, so Refunds and Support write the balance with no check at all.

Refactor

Step 4: Refactor first Back it out

Let's take the history out again. We'll refactor first, so that adding the history becomes a small change, and then we'll add it.

Step 5: Balance becomes a property Encapsulate Field

First, the attribute gets a leading underscore, and a property named balance reads it. A setter with the same name writes it, so Checkout, Refunds and Support run unchanged, but every write now goes through the setter. The underscore only tells readers the attribute is private, and Python doesn't stop anyone, while the setter takes any number, so this alone guards nothing.

Step 6: Charge moves into the card Move Method

The funds check and the subtraction move into Gift Card, as a method called charge. Charge returns True when the card had enough, and False when it didn't. Checkout now calls card dot charge with the amount, and the card still ends at 0.

Step 7: Credit moves into the card Move Method

The refund's addition moves into Gift Card the same way, as a method called credit. Refunds now calls card dot credit, and it no longer reads or writes the balance itself.

Step 8: Cancel moves into the card Move Method

Support's zeroing moves in last, as a cancel method on the card that takes no amount. Support calls card dot cancel, so no class outside Gift Card uses the setter any more.

Step 9: Delete the setter Remove Setting Method

Nothing outside Gift Card sets the balance now, so the setter goes, and the property stays for the script to print. From here on, mypy reports any outside write to balance, and Python raises an Attribute Error if one runs. The balance now changes only inside Gift Card, through charge, credit and cancel. That is encapsulation, where an object keeps its state private and offers methods for the changes it allows, so its state can only change in one place.

Payoff

Step 10: Add the history again Payoff

Now the history goes back in, as a private list inside Gift Card. Charge, credit and cancel each add one line to it. The history property hands out a tuple copy, so callers can read the entries but can't add to the list, and that move is called Encapsulate Collection. Checkout, Refunds and Support didn't change, and the script only gains a line that prints the history.

Step 11: One owner for the balance Payoff

The three writes are gone from the callers, so Checkout calls charge here, Refunds calls credit here, and Support calls cancel here. Cancel now records cancelled 30 before it zeroes the card, so the history and the balance agree. And the funds check sits next to the only code that writes the balance.

Recap

Step 12: Where we started, where we ended Before / after

On the left, the balance is a public attribute, and Checkout, Refunds and Support each write it themselves. On the right, the balance property has no setter, and only charge, credit and cancel change the balance. A caller like Checkout makes one call, and the card decides what happens to its money.

Step 13: When to reach for it Takeaways

Reach for it when several classes change the same value and a rule must hold every time it changes. Skip it when a class only carries data with no rule, like a DTO for one view, because properties there add lines and protect nothing. You'll spot the need by the same attribute assigned in many classes, or by a check copied before each write. Python's own date works this way, because you can read its year, but assigning to it raises an Attribute Error.