Modifying Immutable Objects with Chained Methods

When you are coding with immutable objects, there are many times where you not only need to initially define them but may want to create derivative versions of an existing instance. You might want to consider using chained methods to make your code more concise or to take advantage of default or optional parameters.

You have probably implemented an immutable class with final fields with values passed into the constructor like this immutable ConnectionProperties class below:

  public class ConnectionProperties {
    private final int retries;
    private final int port;
    private final String address;
    private final int timeout;
    private final String username;
    private final int maxAge;
    
    public ConnectionProperties(int retries, int port, String address, int timeout, String username, int maxAge) {
      super();
      this.retries = retries;
      this.port = port;
      this.address = address;
      this.timeout = timeout;
      this.username = username;
      this.maxAge = maxAge;
    }

    //getters go here
  }
}

So you have an immutable class but it can be verbose to construct with so many parameters. The only way to specify default values is to overload the constructor and use defaults, but with this many fields, it can be hard to write and use so many overloads. We could create a builder which would be a respectable solution, but we are going to explore an implementation with chained methods and I’ll discuss why that may be better than a builder, and cases where it is not.

A chained method on an instance of a class is used to return the instance for the purposes of calling another method on the instance. The method usually alters the state of the instance in some way. With immutable classes, we can’t modify the state but must create a new instance with the mutated state. In our chained method, instead of returning the same instance, we build a new mutated instance and return that from the chained method.

  public ConnectionProperties retries(int newRetries) {
    return new ConnectionProperties(newRetries, port, address, timeout, username, maxAge);
  }

  public ConnectionProperties port(int newPort) {
    return new ConnectionProperties(retries, newPort, address, timeout, username, maxAge);
  }

Here we create a new instance of the class each time we call a chained method and return it with one field value changed. The other values passed in to the constructor are the field values in the current instance. Here is an example of how we might use this class:

  properties = new ConnectionProperties(...).port(8080).retries(5);

I left the constructor there a bit vague for reasons you’ll see in a minute. I renamed the parameter by prefixing it with ‘new’. Another alternative would be to not rename the parameters and leave them matching the field name:

  public ConnectionProperties retries(int retries) {
    return new ConnectionProperties(retries, port, address, timeout, username, maxAge);
  }

  public ConnectionProperties port(int port) {
    return new ConnectionProperties(retries, port, address, timeout, username, maxAge);
  }

If you look at the implementation of those two methods the two lines of code to construct the new properties class are identical, and yet they produce two completely different objects. This works because the scope of the variables passed in to the constructor. In the first method, we call the constructor, but the port, address, timeout, username and maxAge parameters all refer to the fields in the current instance. The retries parameter is scoped to the parameter passed into the method call. The object will be constructed with the passed in retries parameter and all the other values will be the same as the current instance field values.

As a bonus, if you use code completion in your IDE, chances are that it will (Eclipse does) autocomplete that constructor with those same parameter names, or you can just copy and paste the constructor line of code so it pretty much writes itself. However, some people might not like this alternative because it isn’t very clear.

Constructors and Default Values

Let’s get back to the constructors for a second since we still need to create that first instance that we are calling our chained methods on. The constructor gives us a chance to set default values on any or all of the fields.

  public ConnectionProperties() {
    this(3,8080,"127.0.0.1",5000,null,10000);
  }

With this constructor, we can create an instance with sensible default values and call the chained methods to create mutated instances. We may even want to make the parameterized constructor protected to hide it from developers.

  properties = new ConnectionProperties().address("192.168.1.11").username("Roger");

This creates a new connection with the default values that only requires minimal property changes. If we add another property to the class, we don’t have to worry about breaking existing code since it will already have a sensible default from the no-arg constructor.

Compared to Builder

One benefit chained methods have over a builder is that this doesn’t require implementing and maintaining a new class and duplicating all the fields and some methods. It also allows you to modify an existing instance, or at least create a new one based on an existing one rather than starting from a blank builder each time:

  public CallResult makeCallWithLongTimeout(ConnectionProperties connectionProperties) {
    ConnectionProperties modfiedProperties = connectionProperties.timeOut(LONG_TIMEOUT);
    return makeCall(modfiedProperties);
  } 

To do this with a builder, we would need to either pass the builder instead of the properties to the method or have a method in the builder that can default it to the existing instance.

  public CallResult makeCallWithLongTimeout(ConnectionProperties connectionProperties) {
    ConnectionPropertiesBuilder builder = new ConnectionPropertiesBuilder().copyFrom(connectionProperties);
    builder.timeOut(LONG_TIMEOUT);
    ConnectionProperties modifiedProperties = builder.build();
    return makeCall(modifiedProperties);
  } 

However, builders allow you to set values one by one in no specific order and then validate them when you call the build() method. We don’t need to worry about whether a field is set or not until the build() method is called and the values validated.

Depending on how you use the values objects, there may be issues with creating an instance of the class without values set. For example, can the username be null, or do we do a null check when we set the value? If we are required to set the value of every field and there is no sensible default then we have lost the ability to have optional constructor arguments.

In general, you want to reduce the amount of initial and maintenance code per field (including testing!) so if you can get away with just using an immutable class with chained methods without a builder, you should. The best use cases for these is where the all values are required and have sensible defaults. The no-arg constructor can build a valid object and we can just tweak the properties on the default as needed.

Comments are closed.