State management explained

State causes a lot of complexity, is the main source of (weird) bugs and introduces high cognitive load to software engineers so it is important to handle it with care. In this article we go into the problems of (mutable) state, how and when to use mutable and immutable state, and how we can use our type system to prevent us from making mistakes.

Why State Management Matters

Most bugs out there are in one way related to state. Some examples are things like race conditions, null pointer exceptions and inconsistent behavior. And let’s not forget the famous quote “Have you tried turning it off and on again” that seems to fix most problems. This tends to work because resetting the device or software causes it to start with a new fresh state. Mutable state can also cause headaches when doing concurrent programming, but because most developers do not write multi-threaded software on a day to day basis, let’s focus for now on problems that are always present: complexity and high cognitive load.

Complexity

Imagine we have a red car that we would like to turn into a blue car. In the real world we would go to a garage to give it a paint job. Typically Object Oriented Programming mimics this by creating a mutable version of a car with a getter and setter.We can then call the setter to make our red car blue.

public class Car {
  private String colour;
 public String getColour() {
    return colour;
  }
  public void setColour(String colour) {
    this.colour = colour;
  }
}

A more functional programming approach would be to omit the setter, thus creating an immutable version of the same car. The real world equivalent of using this would be to create a second identical car next to the old one but use blue paint instead of red paint, and then destroy the red car. While in the real wold this would not be a financially solid decision… in software engineering this does have an advantage. Imagine if I would ask our Object Oriented Programming car what color it is, what would it return? The answer is that you cannot be sure. It depends on when you ask the question, was it before the paint job, or after. Whilst if you ask any of the two functional programming cars what color it is, you will always get back the same answer. And this is why mutable state is always by definition more complex than immutable state. It adds the complexity of time.

value = (state, time)

Cognitive Load

Another example of how mutable state adds to code can be seen when we look at local reasoning; the ability to tell what code does in isolation without having to see the rest of the software. If we have the following function that receives a mutable car, will the two print statements print out the same value?

public void foo(Car redCar) {
  System.out.println(redCar);
  paint(redCar);
  System.out.println(redCar);
}

The answer is, we don’t know. It depends if the paint method paints the car, or makes a painting of a car. The only way to know for sure is to go into the paint method (and down a whole function chain if you are unlucky) and see what happens. This means we cannot apply local reasoning to this bit of code. We need more information from outside of this functions scope. But what if we would create an immutable version of the car, we can do this by using records

public record ImmutableCar(String color, String brand){}

Record classes provide getters, equals and hashcode out of the box. Because there are no setters it cannot be changed after creation. If we would pass this car into the same function, will the two print statements print the same thing?

public void foo(ImmutableCar redCar) {
  System.out.println(redCar); 
  paint(redCar);
  System.out.println(redCar); 
}

The answer is of-course yes. Because the car is immutable it is now impossible for the print statements to print something different. We can now reason about behavior in this code without having to look into the paint method. But what if we do want to change the state of the car? Since we cannot change an ImmutableCar once it is created the only option is to create a new instance of it with different values. So we could create a function like this to change the color of a car to blue.

public ImmutableCar paintBlue(ImmutableCar car) {
  return new ImmutableCar("Blue", car.getBrand());
}

If we now apply this to our original method it would look something like this:

public void foo(ImmutableCar redCar) {
  System.out.println(redCar);
  ImmutableCar blueCar = paintBlue(redCar);
  System.out.println(blueCar);
}

If we would now ask the question, will the two print statements print out the same value, the anwer is… probably not. paintBlue could still just return the input variable, so a state change is not guaranteed, but it does make it explicit that paintBlue can return a new state. Further hints can now be given by assigning the new state to a different variable name that would clarify it. So while we cannot get rid of state changes, using immutable state automatically forces state changes to become explicit, which allows for more local reasoning.

Tradeoffs

Everything in software engineering is a tradeoff, there are no silver bullets. Using immutable state is no different, it also has downsides. The two main downsides are easy of state changes and memory usage.

State Changes

Currently the only native Java way to do a state change with immutable objects is creating a new object. This does mean having to set all the fields even though you only want to change one field. One current workaround is to use Lombok. We can use the @Builder annotation here to add a builder to our ImmutableCar.

@Builder(toBuilder = true)
public record ImmutableCar(String color, String brand) { }

Using this we can now use the toBuilder() method to create a builder that will have all current fields as default values, thus we can use this to only change one field.

ImmutableCar car = new ImmutableCar("Red", "Volvo");
var blueCar = car.toBuilder().color("Blue").build();

This way we can easily create a new immutable object which is a copy except of the old one except for the color attribute. Another option is to use the library RecordBuilder which does something similar. Both are not ideal solutions because they require additional libraries, but the Java team is working hard on JEP 468 to make sure that this becomes a language feature which will look something like this:

ImmutableCar car = new ImmutableCar("Red", "Volvo");
 var blueCar = car with { color = "Blue"; };

Memory / Performance Issues

Having to create a new object every time a state change happens can affect performance. We can see this quite well with the String class. Strings are immutable in Java, which means we have to create a new instance if we want to change a String. In this code we see that we need three String objects to do String concatenation.

var c = "a" + "b";

An object for “a”, “b” and the result in c. If Strings would be mutable we could have added “b” to “a” and we would not have needed a third object. This means concatenating Strings is an expensive operation. We can see this clearly when we try to do this in a loop:

public String foo(String input) {
  var result = input;
  for (var i = 0; i < 1_000_000; i++) {
   result += "0";
  }
  return result;
}

This will take a long time to execute because we keep creating new objects. This is the reason the StringBuilder was introduced, to limit the amount of objects created, since concatenation only happens at the very end when calling toString().

public String foo(String input) {
  var builder = new StringBuilder(input);
  for(var i = 0; i < 1_000_000; i++) {
    builder.append("0");
  }
  return builder.toString();
}

So if this is the case, why did they decide to make String immutable. First of all, in a lot of cases it is not a big problem, as long as you are not concatenating Strings in a huge loop you will not notice performance problems. The same is true for other immutable objects, as long as you are not creating thousands of them within a loop you will not really notice the performance costs.

Sometimes immutable objects can actually be beneficial for memory usage. Because these objects are immutable it is safe to share them across different threads. This also means it is safe to have multiple pointers pointing to the same String. This way if a String is reused it is safe to use the same object instead of having to create a duplicate object. We can see that in the next example

public static void main(String[] args) {
  System.out.println("a" == "a"); // True
  System.out.println("a" == a()); // False
}
public static String a(){
  return "ab".substring(0,1);
}

Because the compiler figured out the Strings “a” are all the same String, it reuses this object instead of creating a new String every time. This is why == comparison sometimes works on Strings. As demonstrated, the compiler cannot always optimize for this, so it is still better to use .equals for comparison.

Best of Both Worlds

We demonstrated the advantages of using immutable state for reducing cognitive load and the advantages of mutable state for performance. They don’t have to be mutually exclusive. As long as the input and output of a function is immutable, we don’t really care about using mutable state within function scope.

public ImmutableCar foo(ImmutableCar car) {
  StringBuilder builder = new StringBuilder(car.brand());
  for (int i = 0; i < 1_000_000; i++) {
    builder.append(i);
  }
  return new ImmutableCar(car.color(), builder.toString());
}

Here both builder and i are mutable variables but the input and output of the function is immutable. No mutable state escapes the function scope, therefor the rest of the application would not see the difference between this function implemented this way or if we would use recursion for example to get rid of these mutable variables. As long as the mutable state stays within function scope it is fully thread safe and the rest of the application can reason in immutable state. This way we have the best of both worlds, we have the performance of mutable state and the reduced cognitive load of immutable state for the rest of the application.

Using the Type System

There is a principle called “Illegal states should not be representable”. This means objects should not be created in an illegal state (we can use the constructor for this) and it should not be able to change to an illegal state (immutability guarantees this). An illegal state in this case is a value that is not supposed to be there or does not fit certain constraints. Or if a value is null when it should not be null. But some values can be null like in the following example:

public record Person(
  String firstName,
  @Nullable String middleName,
  String lastName
){}

Here middleName is nullable because it is an optional field. Since not everyone has a middle name this is a true optional field of Person. But not every nullable field is really an optional field. If we take a look at the following class:

public record Contract(
  String text,
  @Nullable String signature
){}

we can see that signature is nullable. It is however not an optional field, instead it represents a state change. When the contract is created there will not be a signature, but at some point in time the signature will be provided and should not be null afterwards. Since the field is nullable we still need to take this into account in the rest of our application, resulting in code like this:

public void startProject(Contract contract) {
  if(contract.signature() == null){
    throw new IllegalStateException();
  }
}

Where in order to start our project we need to check if Contract is in a legal state in order to continue. This can lead to a lot of checks in our code base because we cannot guarantee that Contract is in a legal state at that point in time. An alternative approach is capturing this state change in our type system.

public record UnsignedContract(String text){}
public record SignedContract(String text, String signature){}

By creating a separate class for both states, we now see that both classes only contain non-null fields. This makes sure that we do not need to do any null checks in our code base anymore.

public void startProject(SignedContract contract) {
// No null check needed
}

There still needs to be validation to make sure there is a signature, but this can now be moved to the creation of the SignedContract class. This means that this validation only needs to take place at class creation and all other parts of the code can rely on a safe, immutable object.

Conclusion

We learned that by using immutable objects we are required to code in certain ways that tend to reduce cognitive load by enabling local reasoning and making state changes explicit. Mutable state is not necessarily bad, but try to keep it within function scope so it does not affect the rest of the application. And by using the type system to represent different state changes in different classes we make sure that classes are always created in a correct an trustworthy state.

Total
0
Shares
Previous Post

03-2025 1I2 | 30 Years Of JAVA – (Part 3 1I2) – Special Edition

Next Post
Software Architecture

Demystifying Software Architecture: Styles/Patterns – PART 2

Related Posts
Java turns 30 in 2025

30 Years of Java, 25 Years of Enterprise Java

Over the last three decades, technology has been evolving at a breakneck pace, with innovations constantly redefining every aspect of application development. In such a fast-moving and dynamic landscape, few technologies stand the test of time, especially in computer science. Yet, Java has done just that. As we celebrate 30 years of Java and 25 years of enterprise Java, it’s clear that these solutions have not only endured but thrived—adapting, advancing and proving their lasting value to software engineers worldwide.
Steve Millidge
Read More