Immutable by default: How to avoid hidden state bugs in OOP

Aug 18, 2025·
Gert de Pagter
Gert de Pagter
· 7 min read

One of the harder bugs to debug are bugs dealing with mutable data. A while ago we ran into a problem where the last_modified date of entities was always 1 day off. See if you can spot the issue in the code below:

class MyEntity {
    public function __construct(public DateTime $lastModified) {}
}

function saveEntity(Notifier $notifier) {
    $now = new DateTime();
    $entity = new MyEntity($now);
	  $notifier->notifyAt($now->modify('+1 day'));
}

The problem here is that the $now->modify() call mutates (changes) the data of the original object. Since both $now and $entity->lastModified point to the same DateTime object in memory, the $entity->lastModified is also updated.

If we had used DateTimeImmutable, the modify() method would have returned a new object instead of changing the original. The original DateTimeImmutable would stay untouched, meaning $lastModified wouldn’t be updated.

In this example these lines are close to each other, but in reality the entity, and the notifier code were in entirely different places, yet they still used the same DateTime object, thus causing the bug. This cost us multiple hours of debugging, and is why I default to immutable objects.

What does (im)mutable mean?

Immutable means your data cannot be changed, and mutable means it can be changed. When talking about immutability there is a difference between deep and shallow immutability. Shallow means the reference itself is immutable, deep means the reference and everything inside is immutable.

Let’s take the JavaScript const and let as an example. const variables are immutable, and let variables are mutable, meaning we can override a let, but not a const

const immutable = 1;
immutable = 2; // TypeError: invalid assignment to const 'immutable'
let mutable = 1;
mutable = 2; // Allowed.

However, const in JavaScript only gives shallow immutability, meaning that we can change the data inside of it.

const immutable = {
    prop: 1,
};
immutable.prop = 2; // Allowed, since we don't change `immutable`, but its contents
immutable = {
    prop: 2,
}; // TypeError: invalid assignment to const 'immutable'

We can (almost) achieve deep immutability with Object.freeze. This marks each property as immutable. However, nested objects can still be modified if they are not frozen as well.

const immutable = Object.freeze({prop: 1});
immutable.prop = 2; // TypeError: Cannot assign to read only property 'prop' of object

const nested = Object.freeze({outer: { inner: 1}});
nested.outer.inner = 2; // Allowed, since the `inner` object is not frozen.

const nested = Object.freeze({outer: Object.freeze({ inner: 1})});
nested.outer.inner = 2;  // TypeError: Cannot assign to read only property 'inner' of object

Most languages have ways to achieve either shallow or deep immutability. It’s good to know which one you are dealing with so you don’t make any wrong assumptions.

Advantages of immutable data

Easier to reason about bugs

Mutable nested objects can be silently changed from anywhere in your code, making it harder to find the root cause of a bug.

To illustrate this, let’s look at a more complete version of the example at the beginning of this post. When reading the Persister code, nothing seems out of the ordinary, and when looking at usages of $entity->lastModified, nothing comes up either. You’d have to check inside the Notifier class to see the modification happening, and know its the same instance as the $entity->lastModified.

class Notifier {  
    public function notifyAfter(DateTime $now, int $seconds = 24*60*60) {  
        $dateTime = $now->modify("+{$seconds} seconds");  
        // Schedule the notification
    }  
}

class MyEntity {  
    public function __construct(public DateTime $lastModified) {}  
}

class Persister {  
    public function __construct(private Notifier $notifier) {}  

    public function persist(DateTime $currentTime, MyEntity $entity) {  
        $this->notifier->notifyAfter($currentTime);  
        // Persistence logic
    }  
}

$persister = new Persister(new Notifier());

// The entity is created with the current time  
$now = new DateTime();  
$persister->persist($now, new MyEntity($now));

If we had used a DateTimeImmutable instead, then it would have been impossible to change the inner data of the $entity->lastModified from anywhere else. Since the modify method would just return a new instance, rather than change the original data.

Concurrency

When you are using concurrency (for example, with async await in JavaScript), and using mutable data, then it’s easy to create race conditions, where it becomes impossible to reason about the end result of the code. Take the code below, where all promises are changing the data.count value. Since one may take longer than the other, it’s impossible to determine what the end result of data.count is going to be.

const getRandomInt = (max: number) => Math.floor(Math.random() * max);  
  
const executeAfter = (callback: () => void, milliSeconds: number) => {  
    return new Promise(resolve => setTimeout(() => {  
        resolve(callback());  
    }, milliSeconds))  
}  
  
const data = {  
    count: 0,  
}  
  
await Promise.all([  
    executeAfter(() => {data.count = 1}, getRandomInt(10)),  
    executeAfter(() => {data.count = 2}, getRandomInt(10)),  
    executeAfter(() => {data.count = 3}, getRandomInt(10)),  
    executeAfter(() => {data.count = 4}, getRandomInt(10)),  
    executeAfter(() => {data.count = 5}, getRandomInt(10)),  
]);  

console.log(data.count); // Impossible to guess what this value is going to be.

Instead we could have each promise return the new count, and use the last value.

const results = await Promise.all([  
    executeAfter(() => 1, getRandomInt(10)),
    executeAfter(() => 2, getRandomInt(10)),
    executeAfter(() => 3, getRandomInt(10)),
    executeAfter(() => 4, getRandomInt(10)),
    executeAfter(() => 5, getRandomInt(10)),
]);  

console.log(results[results.length - 1]); // Always 5

Moving from mutable to immutable objects may require some refactoring. The changes required will depend on your use case. But usually returning a value instead of mutating a property is a good starting point.

When not to use immutability

While immutability has clear benefits, there are situations where mutability is the better choice.

Performance overhead

Creating new objects instead of modifying existing ones has a cost. Take the 2 classes below for example. Calling $mutable->add() 1 million times takes about 150-170ms on my machine. Calling $immutable->add() 1 million times, takes about 300ms. If every microsecond counts, then the overhead caused by a pattern where you return a new object every time, may not be worth it.

class Mutable {  
    public function __construct(  
        public int $counter = 0,  
    ) {}  
  
    public function add(): void{  
        $this->counter++;  
    }  
}  
  
class Immutable {  
    public function __construct(  
        public int $counter = 0,  
    ) {}  
  
    public function add(): self {  
        return new self($this->counter + 1);  
    }  
}

Short-lived builder objects

If you are using a builder design pattern, then it is generally understood that you are making changes to this object directly, and that you are mutating state. And while you could use an immutable builder, it’s generally okay to go with mutable data, as the builder object is controller and short-lived. It even helps in keeping the created object immutable, as you have done all modifications needed in the builder phase.

Writing immutable objects

Using keywords in your language of choice like const, readonly, or final isn’t the only thing you need to change when writing immutable objects. There are a few patterns that you’ll likely see when writing or interacting with immutable objects.

With instead of set

Mutable objects usually have setters, which directly change the object. When you want to get rid of those, but still need a way to change data, you can use the “with” pattern. A setX method will change x on the object. A withX method will return a new object where X is changed to the new value.

class MyMutableClass
{
     public function __construct(
         private int $count,
         private string $name,
     ) {}

	public function setCount(int $newCount): void {
	    $this->count = $newCount;
	}

	public function setName(string $newName): void {
		$this->name = $newName;
	}
}

readonly class MyImmutableClass {
	public function __construct(
         private int $count,
         private string $name,
     ) {}

	public function withCount(int $newCount): self {
		return new self($newCount, $this->name);
	}

	public function withName(string $newName): self {
		return new self($this->count, $newName);
	}
}

Conclusion

The DateTime bug I opened this post with isn’t a rare occurrence, it can happen any time you deal with mutable state. If we had used immutability, the bug couldn’t have existed. That’s why I default to immutable objects, and only use mutability when I truly have to.

Immutability isn’t always the easiest path, but it’s almost always the safer one. So go and make the next class you write immutable. You might never go back.

Do you agree that immutable should be the default? Or do you prefer the flexibility of mutable objects? Let me know in the comments.

If you want to get notified of the next blog post, join the newsletter.

Gert de Pagter
Authors
Software Engineer
My interests include software development and math.