DEV Community

Timo Schinkel
Timo Schinkel

Posted on

The case for immutability

Some common questions I get on pull requests are "what's with those with-methods?" and "why do you use DateTimeImmutable?". The reason is I am a fan of immutability. I became a fan of immutability the hard way; By introducing bugs caused by (unintentional) mutability.

What is immutability

When an object is immutable its state can not be modified after the object has been created. Concrete this means that an immutable object does not have any writable properties nor any setters to update the properties.

For clarification; The most simple implementation of a mutable object:

final class MutableObject
{
    public string $type = '';
}
Enter fullscreen mode Exit fullscreen mode

A more elaborate implementation could have a constructor and a setter. Using a constructor and a setter allows for validation on the value for both initialization and for mutation:

final class MutableObject
{
    private string $type;

    public function __construct(string $type)
    {
        $this->setType($type);
    }

    public function setType(string $type): void
    {
        if (empty($type)) {
            throw new InvalidArgumentException('Type cannot be empty');
        }

        $this->type = $type;
    }

    public function getType(): string
    {
        return $this->type;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why make an object immutable

In short; To guarantee the validity of an object.

In PHP scalar values - integer, float, string and boolean are passed by value by default. That means that in a function or method where I pass an integer as argument we can assign the value of that integer without any side effects on the code that made the call:

function doSomething(int $number): void
{
    $number += 5;
}

$number = 12345;
echo $number . PHP_EOL;

doSomething($number);
echo $number . PHP_EOL;
Enter fullscreen mode Exit fullscreen mode

This above example will output:

12345
12345
Enter fullscreen mode Exit fullscreen mode

Objects and arrays1 are by default passed by reference. That means that any changes I apply on an object inside a function or method that was passed as an argument will have side effects on the case that made the call:

function doSomething(DateTime $date): void
{
    $date->add(new DateInterval('P1D'));
}

$date = new DateTime("2022-04-26");
echo $date->format('Y-m-d') . PHP_EOL;

doSomething($date);
echo $date->format('Y-m-d') . PHP_EOL;
Enter fullscreen mode Exit fullscreen mode

This above example will output:

2022-04-26
2022-04-27
Enter fullscreen mode Exit fullscreen mode

The contents of $date has been mutated. And there is no way to prevent this in PHP. Apart from making sure your objects can not be mutated. With an immutable object you can be sure that another piece of code does not (accidentally) change the object causing unexpected side effects. Let's assume another example - a scenario I actually encountered myself:

$today = new DateTime(); // this could be injected from a clock

$openingHours = $this->openingHoursService->getOpeningHoursForUpcomingDays($store, $today, 7);

if ($this->isCurrentlyClosed($today, $openingHours)) {
    // try to find an alternative that is currently open
    $currentlyOpenStores = $this->openingHoursService->getCurrentlyOpen($today);
    // ...
}
Enter fullscreen mode Exit fullscreen mode

After releasing this change it took a few days for one of the stakeholders to start complaining about wrong information being shown. We were unable to reproduce this until a colleague decided to set $today to the moment the stakeholder reported the issue. Now we saw the wrong information as well! Stepping through the code we discovered that properties of $today were changing after calling the opening hours service. We had a look at the implementation and found that it queried the database as follows:

select
    open, closed
from
    opening_hours
where 
    store = {store_id}
    and open >= {$today}
    and open < {$today->add(new DateInterval("P${days}D"))}
Enter fullscreen mode Exit fullscreen mode

Because $today is an instance of DateTime calling the add() method on it actually updated the internal value of $today.

We can now get into a discussion about this implementation. If the developer that implemented this method had used the date manipulation features of the database this issue would not have existed. More important is that unexpectedly an unrelated piece of code changed "my object". This is exactly the scenario where immutability can help; If we were to pass an immutable object we can be absolutely certain that no matter how getOpeningHoursForUpcomingDays() is implemented the object that we pass to it will never change.

The solution for this specific scenario was to replace the DateTime with DateTimeImmutable. It required a change in signature for the service we called, but from that moment on our bug was fixed.

How to implement an immutable object

The immutable equivalent of MutableObject could look something like this:

final class ImmutableObject
{
    private string $type;

    public function __construct(string $type)
    {
        if (empty($type)) {
            throw new InvalidArgumentException('Type cannot be empty');
        }

        $this->type = $type;
    }

    public function getType(): string
    {
        return $this->type;
    }
}
Enter fullscreen mode Exit fullscreen mode

So what about those "with-methods" mentioned earlier. Ideally all properties are to be part of the constructor. This can lead to some readability difficulties when working with a large amount of properties and when some properties are optional. Another scenario is when you want to change one or more values after instantiation of the object. The convention we have been using - and that is used in PSR's as well - is to use methods that start with with:

final class ImmutableObject
{
    private string $type;
    private ?string $label = null;

    public function __construct(string $type, ?string $label = null)
    {
        if (empty($type)) {
            throw new InvalidArgumentException('Type cannot be empty');
        }

        $this->type = $type;
        $this->label = $label;
    }

    public function getType(): string
    {
        return $this->type;
    }

    public function withLabel(string $label): static
    {
        return new self($this->type, $label);
    }
}
Enter fullscreen mode Exit fullscreen mode

Another implementation uses clone. This implementation is useful when an object had a high number of properties or when the validity of your object requires a combination of arguments to be specified2:

final class ImmutableObject
{
    private string $type;
    private ?string $label = null;

    public function __construct(string $type)
    {
        if (empty($type)) {
            throw new InvalidArgumentException('Type cannot be empty');
        }

        $this->type = $type;
    }

    public function getType(): string
    {
        return $this->type;
    }

    public function withLabel(string $label): static
    {
        $clone = clone $this;
        $clone->label = $label;

        return $clone;
    }
}
Enter fullscreen mode Exit fullscreen mode

Instantiating these objects becomes a bit more verbose, but will almost resemble prose:

$object = (new ImmutableObject('my-type'))
    ->withLabel('My type');
Enter fullscreen mode Exit fullscreen mode

In the examples shown so far I have opted for verbosity. With the syntactic sugar that is introduced in PHP 8 and PHP 8.1 we can shorten object definitions drastically:

final class ImmutableObject
{
    public function __construct(
        public readonly string $type
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

This example will make the property available for usage by other objects. If you prefer to use methods for retrieval of data all you need to do is make the property private and introduce a getter similar to the examples above.

Should every object be immutable?

Short answer: no. In my opinion writing code is like being in traffic; You need to be predictable. I think most data containers would benefit from immutability. There are however some scenarios where a mutable object makes more sense as some objects are expected to maintain state. For example a form abstraction; A form contains objects that represent the fields and these fields have a value. This value can be pre-populated, but will be updated based on the info that is submitted. In that scenario it makes sense that the form instance has some form of state.

tl/dr;

Mutable objects can cause bugs that might be difficult to find. By making object immutable you have the guarantee that you are in control of your objects and their state. Making objects in immutable requires only a relative small amount of code.


  1. Arrays are technically passed by reference, but because most operations on an array creates a new array instance this is not very apparent. Calling methods that change the internal pointer of an array - like reset() and next() - do have effects on the array instance and can therefore "leak".  

  2. PHP 8 has introduced named arguments. This makes working with a large number of arguments easier. The downsides of this are that your argument names have now become part of your public facing API and you will have to add more complex validation rules when arguments can only be set in combination with other arguments. 

Discussion (0)