Immutability: value objects instead of setters
Share small objects like prices safely, so one change never leaks into another.
Watch the narrated video
Every step
Problem
Steps 1–3Refactor
Steps 4–6Payoff
Steps 7–8PHP bonus
Step 9Recap
Steps 10–11The code: before and after
Before
declare(strict_types=1);
final class Price
{
public function __construct(
private int $amount,
private string $currency,
) {
}
public function getAmount(): int
{
return $this->amount;
}
public function getCurrency(): string
{
return $this->currency;
}
public function setAmount(int $amount): void
{
$this->amount = $amount;
}
}
final class Booking
{
public function __construct(
private readonly string $car,
private Price $price,
) {
}
public function line(): string
{
return $this->car . ': ' . $this->price->getAmount()
. ' ' . $this->price->getCurrency();
}
public function sameRateAs(Booking $other): bool
{
return $this->price === $other->price;
}
}
$weekend = new Price(120, 'USD');
$ana = new Booking('Golf', $weekend);
$ben = new Booking('Polo', $weekend);
echo $ana->line(), "\n";
echo $ben->line(), "\n";
var_export($ana->sameRateAs($ben));After
declare(strict_types=1);
final readonly class Price
{
public function __construct(
public int $amount,
public string $currency,
) {
}
public function plus(int $extra): Price
{
$amount = $this->amount + $extra;
return new Price($amount, $this->currency);
}
public function equals(Price $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
final class Booking
{
public function __construct(
private readonly string $car,
private Price $price,
) {
}
public function line(): string
{
return $this->car . ': ' . $this->price->amount
. ' ' . $this->price->currency;
}
public function sameRateAs(Booking $other): bool
{
return $this->price->equals($other->price);
}
public function addExtra(int $extra): void
{
$this->price = $this->price->plus($extra);
}
}
$weekend = new Price(120, 'USD');
$ana = new Booking('Golf', $weekend);
$ben = new Booking('Polo', $weekend);
$ana->addExtra(15);
echo $ana->line(), "\n";
echo $ben->line(), "\n";
var_export($ana->sameRateAs($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, and it has a setter, a method that changes one field, here set amount. 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 desk 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 reads the amount and sets it fifteen higher. 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 setter called through one booking changes the price the other booking reads. You can see it in the setter call 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: No setter, readonly properties Remove Setting Method
The first move is Remove Setting Method, so we delete set amount, and nothing called it, so the output stays the same. Both properties become readonly, so PHP throws an error if any code writes them after the constructor, and PHPStan reports it before the code runs. That makes Price immutable, which means its fields never change after it is made.
Step 6: Equal by value Change Reference to Value
The old triple equals 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 never 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 equals method that compares the amount and the currency. Same rate as now calls equals, and the run still prints true. The desk still hands the same weekend price to both bookings, and now that's safe, because nobody can change 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 desk 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 setter call 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 property may point somewhere new, but the Price it points at never changes.
PHP bonus
Step 9: Price as a readonly class PHP bonus
Price still spends eight lines on two getters that only hand out values it keeps private. A readonly class, from PHP eight point two, makes every property readonly, so amount and currency can be public, and the getters go. An earlier lesson promoted properties to stop repeating them, but here readonly is what makes a public property safe, because no booking can write it. Plus and equals stay as they were, and line now reads amount and currency straight from the price.
Recap
Step 10: Where we started, where we ended Before / after
On the left, Price had a setter, so any booking that held it could change it for everyone. Same rate as compared references, which only worked because the bookings shared one object. On the right, Price is a readonly class, and plus returns a new Price instead of changing the old one. Ana's extra replaces the price in her booking only, and equals 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 a setter on an object that more than one owner holds, or by defensive copies before every change. PHP's Date Time Immutable works this way, so modify returns a new date and leaves the old one alone.