I'll Be Backend
Principles · Python

Immutability: value objects instead of setters

Share small objects like prices safely, so one change never leaks into another.

11 steps5 chapters~5 min at 1.25×#oop#immutability#value objects#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–6

Payoff

Steps 7–8

Python bonus

Step 9

Recap

Steps 10–11

The code: before and after

Before

class Price:
    def __init__(self, amount: int, currency: str) -> None:
        self.amount = amount
        self.currency = currency


class Booking:
    def __init__(self, car: str, price: Price) -> None:
        self.car = car
        self.price = price

    def line(self) -> str:
        p = self.price
        return f"{self.car}: {p.amount} {p.currency}"

    def same_rate_as(self, other: "Booking") -> bool:
        return self.price is other.price


weekend = Price(120, "USD")
ana = Booking("Golf", weekend)
ben = Booking("Polo", weekend)
print(ana.line())
print(ben.line())
print(ana.same_rate_as(ben))

After

@dataclass(frozen=True)
class Price:
    amount: int
    currency: str

    def plus(self, extra: int) -> "Price":
        return Price(self.amount + extra, self.currency)


class Booking:
    def __init__(self, car: str, price: Price) -> None:
        self.car = car
        self.price = price

    def line(self) -> str:
        p = self.price
        return f"{self.car}: {p.amount} {p.currency}"

    def same_rate_as(self, other: "Booking") -> bool:
        return self.price == other.price

    def add_extra(self, extra: int) -> None:
        self.price = self.price.plus(extra)


weekend = Price(120, "USD")
ana = Booking("Golf", weekend)
ben = Booking("Polo", weekend)
ana.add_extra(15)
print(ana.line())
print(ben.line())
print(ana.same_rate_as(ben))

Transcript, step by step

Problem

Step 1: One weekend rate Before

This is the same car rental desk that sent receipts last time, and today it prices bookings. A Price holds an amount and a currency as public attributes, so any code can assign them, and that assignment is the setter here, a way to change one field. A Booking holds a car and a price, prints one line, and checks whether two bookings pay the same rate by asking if they hold the same Price object. The script makes one weekend rate and gives it to two customers, Ana and Ben, and the run prints Golf and Polo at a hundred twenty dollars each, then true. Both bookings cost the same, so making the weekend price once and passing it to both looked like the obvious choice.

Step 2: Ana adds a child seat Code smell

Ana adds a child seat for fifteen dollars. The quick way gives Booking an add extra method that adds fifteen to the price's amount in place. Ana's line prints a hundred thirty five, but Ben's line prints a hundred thirty five as well, and Ben never asked for a seat.

Step 3: A shared mutable price Code smell

A mutable object is one whose fields can change after it is made, and a shared reference is two variables that point at the same object. Put them together and you get a shared mutable price, so a write through one booking changes the price the other booking reads. You can see it in the write here, in Ana's booking here, and in Ben's booking, which holds the same object, here. The bug sits in neither line alone, so to find it you have to trace every place a Price is shared. The usual workaround is to copy the Price before each change, and one forgotten copy brings the bug back.

Refactor

Step 4: Refactor first Back it out

So let's take the child seat out again. We'll refactor first, so a Price can't change under anyone, and then we'll add the seat.

Step 5: Read-only properties Remove Setting Method

The first move is Remove Setting Method, so the amount and the currency move into fields with a leading underscore. The leading underscore tells readers they're private, and two read-only properties hand the values out with no setter at all. Nothing wrote to a price before, so line still reads amount and currency, and the output stays the same. That makes Price immutable, which means its fields never change after it is made, and mypy now reports any assignment to amount.

Step 6: Equal by value Change Reference to Value

The old is check only said true because both bookings share one object, and two separate prices of a hundred twenty dollars would have said false. But a Price can't change now, so two prices with the same amount and currency can stand in for each other. That's a value object, a small immutable object that is equal to another when its values are equal. So the move is Change Reference to Value, and Price gets an eq method and a hash method that compare the amount and the currency. Same rate as now uses double equals, and the run still prints true. The script still hands the same weekend price to both bookings, and now that's safe, because no code writes to it.

Payoff

Step 7: The child seat, again Payoff

Now the child seat comes back, and Price gets a plus method that returns a new Price with the extra added. Add extra stores that new Price in Ana's booking, and the script calls it the same way as before. The run prints Golf at a hundred thirty five, Polo at a hundred twenty, and false, because the two rates now differ. The write is gone, Ana's booking now holds a new Price, and Ben's still holds the weekend one. The two weekend arguments, Ben's print line and the same rate as method didn't change in this step.

Step 8: Replaced, never changed Value object

A value object is never changed, only replaced. It fits here because two bookings share the weekend Price, so replacing it in Ana's booking can't reach Ben's. The booking is different, because it's an entity, an object with its own identity whose state may change. It's Ana's booking, so its price attribute may point somewhere new, but the Price it points at never changes.

Python bonus

Step 9: Price as a frozen dataclass Python bonus

Price was a whole class of underscore fields, a constructor, two properties and a hand written eq and hash, all to hold two values. A frozen dataclass writes the constructor, the equality and the hash from these two fields. An earlier lesson used a dataclass to stop naming each field three times, but here frozen matters most, because Python now raises an error when any code assigns a field. Plus stays, and it reads amount and currency straight from the fields.

Recap

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

On the left, Price had public fields, so any booking that held it could change it for everyone. Same rate as compared identity, which only worked because the bookings shared one object. On the right, Price is a frozen dataclass, and plus returns a new Price instead of changing the old one. Ana's extra replaces the price in her booking only, and same rate as compares values.

Step 11: When to reach for it Takeaways

Reach for it when a small object, like a price, a date range or an address, is passed around and shared. Skip it when the object has its own identity, like Ana's booking, because code that holds it must see its changes, and a copy would leave it looking at the old booking. You'll spot it by writes to an object that more than one owner holds, or by defensive copies before every change. Python's date works this way, so adding a time delta returns a new date and leaves the old one alone.