Immutable objects in practical use

Rafał Łomotowski

The purpose of this article is to show you how to create an immutable class in Java and why it is so powerful.

When an object is immutable?

In programming, an object is immutable when its state is unchangeable after it was created. We can’t write or update any of the fields. A good example of this kind of object is String or Integer because their content never changes. For every update of state we need to create a new object. Immutable objects offer some useful benefits.

This kind of objects are thread-safe, and we don’t have to worry about applications working in concurrent or multithreaded environments. This helps to save time during debugging the application.

Another benefit is operating in the complex domain of business needs. When you have many business operations, the use of immutable objects helps you to monitor how these operations are connected and how those are influencing the application state.

When we use a memory cache, an immutable class is also a very good guarantee of the unchangeable state. Additionally, an immutable object is a great thing to use as the key for a map.

How to create an Immutable Class?

To create an immutable class we have to follow these rules:

  • Make the class final so that it can’t be inherited.
  • All fields should be private final.
  • An immutable object can’t contain setter methods.
  • All fields should be initialized through a constructor or builder (Lombok helps here).
    NOTE! If fields are mutable complex objects(e.g. HashMap) basic getters or constructors (like those from Lombok) are not enough.

Here is an example how to create an immutable class, and we will see the benefits of using it:

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

import java.math.BigDecimal;

@Getter
@Builder
@EqualsAndHashCode
@RequiredArgsConstructor
public final class PlaneModel {
    private final String model;
    private final BigDecimal weight;
    private final Integer amountOfSeats;
}
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Slf4j
public class AircraftFleet {
    private final Map<PlaneModel, Integer> createdPlanes = new HashMap<>();

    public void addCreatedPlane(PlaneModel planeModel) {
        Integer numberOfPlanes = getNumberOfPlanesByModel(planeModel);
            createdPlanes.put(planeModel, numberOfPlanes + 1);
    }

    public Integer getNumberOfPlanesByModel(PlaneModel planeModel) {
        return Optional.ofNullable(createdPlanes.get(planeModel))
                .orElse(0);
    }

    public void showCreatedPlanes() {
        for (Map.Entry<PlaneModel, Integer> entries : createdPlanes.entrySet()) {
            PlaneModel planeModel = entries.getKey();
            Integer numberOfPlanes = entries.getValue();

            log.info(String.format("Model: %s , weight: %s , number of seats: %d , number of planes: %d",
                    planeModel.getModel(), planeModel.getWeight(), planeModel.getAmountOfSeats(), numberOfPlanes));
        }
        System.out.println("");
    }
}

As you can see the AircraftFleet does not have Lombok @Getter as it would allow the access to original createdPlanes directly which could be modified, and that would lead to an unexpected and not wanted outcome. If we want to continue to give users the ability to return the current state, we should always return a copy of it inside the getter so that we don’t pass the internal reference outside the class. We can also return just a read-only view of it using Collections.nmodifiableMap() method.

@Slf4j
public class AircraftFleet {
    private final Map<PlaneModel, Integer> createdPlanes;

    public AircraftFleet(Map<PlaneModel, Integer> initialPlanes) {
        this.createdPlanes = new HashMap<>(initialPlanes);
    }

    public Map<PlaneModel, Integer> getPlanes() {
        return new HashMap<>(createdPlanes); // or Collections.unmodifiableMap(createdPlanes);
    }

    // rest of the code

Let’s now see how we can use our immutable objects.

@Slf4j
public class Application {

    public static void main(String[] args) {
        String modelOfFirstPlane = "Airbus A310";
        BigDecimal weightOfFirstPlane = BigDecimal.valueOf(141);
        Integer amountOfSeatsInFirstPlane = 200;

        String modelOfSecondPlane = "Boeing 737";
        BigDecimal weightOfSecondPlane = BigDecimal.valueOf(105);
        Integer amountOfSeatsInSecondPlane = 120;

        PlaneModel firstPlaneModel = PlaneModel.builder()
                .model(modelOfFirstPlane)
                .weight(weightOfFirstPlane)
                .amountOfSeats(amountOfSeatsInFirstPlane)
                .build();

        PlaneModel secondPlaneModel = PlaneModel.builder()
                .model(modelOfSecondPlane)
                .weight(weightOfSecondPlane)
                .amountOfSeats(amountOfSeatsInSecondPlane)
                .build();

        PlaneModel sameAsSecondPlaneModel = PlaneModel.builder()
                .model(modelOfSecondPlane)
                .weight(weightOfSecondPlane)
                .amountOfSeats(amountOfSeatsInSecondPlane)
                .build();        

        AircraftFleet aircraftFleet = new AircraftFleet();
        aircraftFleet.addCreatedPlane(firstPlaneModel);

        log.info("Add first plane. Before attempt to change name of model:");
        aircraftFleet.showCreatedPlanes();

        modelOfFirstPlane = "Modified Model of " + modelOfFirstPlane;

        log.info("After attempt to change name of model:");
        aircraftFleet.showCreatedPlanes();

        log.info("Add second plane model:");
        aircraftFleet.addCreatedPlane(secondPlaneModel);
        aircraftFleet.showCreatedPlanes();

        log.info("Add next plane of second model");
        aircraftFleet.addCreatedPlane(sameAsSecondPlaneModel);
        aircraftFleet.showCreatedPlanes();
    }
}

As you can see, we have created an Immutable class called PlaneModel which will be used as a complex object key of the Map, and we can gather a number of planes per model.

@EqualsAndHashCode
@RequiredArgsConstructor
public final class PlaneModel {

Additionally, we have used annotation from the Lombok dependency to create a contract between the equals and hashcode, which is important to use as key in the Map. If contract wasn’t kept then it could lead to Keys Inconsistencies and for example we would have identical PlaneModels as a separate set of plane quantities.

Here we have whole output:

Add the first plane. This what we can see before the attempt to change name of the model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

After the attempt to change the name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Add the second plane model:
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 1
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

Add the next plane of second model
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 2
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1


Process finished with exit code 0

Let’s have a look at the first part of output:

Add the first plane. Before attempt to change name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

We have added the first PlaneModel, and we can see the number of planes equals 1

Then we try to change the name of model:

modelOfFirstPlane = "Modified Model of " + modelOfFirstPlane;

The output says that nothing has changed. The immutability of String class caused creation of a new String object so finally the original String has not changed:

After attempt to change name of model:
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

At the end we have added the second PlaneModel:

aircraftFleet.addCreatedPlane(secondPlaneModel);
aircraftFleet.showCreatedPlanes();

the following output:

Add the second plane model:
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 1
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

As we can see we have one plane for each PlaneModel.

Let’s try to add the next unit for the second PlanenModel:

aircraftFleet.addCreatedPlane(sameAsSecondPlaneModel);
aircraftFleet.showCreatedPlanes();

the final output as follows:

Add the next plane of second model
Model: Boeing 737 , weight: 105 , number of seats: 120 , number of planes: 2
Model: Airbus A310 , weight: 141 , number of seats: 200 , number of planes: 1

We can draw conclusions that the Immutability of the PlaneModel and contract equals + hashCode works perfectly because we have one unit for the first PlaneModel and two units for the second PlaneModel even when we have two created objects with the same content. No situation has created the third separated PlaneModel.

What are the benefits of using Immutable Objects in a memory cache?

When we want to use a memory cache in our application then it’s good way to create an immutable Object as a Key of the Cache and use the contract between equals and hashcode to avoid inconsistencies in storing data.

In our example we will use @Cacheable mechanism with EhCache implementation in Spring Boot

Below is configuration of the dictionary storage cache

import net.sf.ehcache.config.CacheConfiguration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.ehcache.EhCacheCacheManager;
import org.springframework.context.annotation.Bean;

@EnableCaching
@Configuration
public class ApplicationCacheConfiguration {

    public static final String DICTIONARY_CACHE_NAME = "DICTIONARY";

    private static final long TIME_TO_LIVE_SECONDS = 600;
    private static final long MAX_ENTRIES = 50;

    @Primary
    @Bean
    CacheManager cacheManager() {
        return new EhCacheCacheManager(createCacheManager());
    }

    private net.sf.ehcache.CacheManager createCacheManager() {
        net.sf.ehcache.config.Configuration config = new net.sf.ehcache.config.Configuration();
        config.addCache(getDictionaryCacheConfiguration());
        return net.sf.ehcache.CacheManager.newInstance(config);
    }

    private CacheConfiguration getDictionaryCacheConfiguration() {
        CacheConfiguration cacheConfiguration = new CacheConfiguration();
        cacheConfiguration.setName(DICTIONARY_CACHE_NAME);
        cacheConfiguration.setTimeToLiveSeconds(TIME_TO_LIVE_SECONDS);
        cacheConfiguration.setMaxEntriesLocalHeap(MAX_ENTRIES);
        return cacheConfiguration;
    }
}

Let’s create an Immutable Class as the unique Key and the @EqualsAndHashCode contract for caching dictionaries:

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

@Getter
@EqualsAndHashCode
@RequiredArgsConstructor
public final class DictionaryId {
    private final String pageName;
    private final Integer line;
}

Here we have the mechanism of caching where DictionaryId is our key

@Repository
@RequiredArgsConstructor
class DictionaryRepositoryImpl implements DictionaryRepository {

    private final DictionaryJpaRepository dictionaryJpaRepository;

    @Override
    @Cacheable(value = ApplicationCacheConfiguration.DICTIONARY_CACHE_NAME)
    public String getDictionary(DictionaryId dictionaryId) {
        return dictionaryJpaRepository.getDictionary(dictionaryId); // Getting dictionary from Database
    }
}

We have provided a cache for dictionaries, and we can make sure that two different objects with the same content of the Key will return the same data from cache storage.

But what if our Key was not immutable and without @EqualsAndHashCode?

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;

@Getter
@AllArgsConstructor
public final class DictionaryId {
    private String pageName;
    private final Integer line;

    public void updatePageName(String pageName) {
        this.pageName = pageName;
    }
}

Let’s say we have retrieved and a cached dictionary:

DictionaryId dictionaryId = new DictionaryId("FirstPage", 5);
String dictionaryValue = dictionaryRepository.getDictionary(dictionaryId);

Suppose that accidentally and unwittingly we updated the Key in the business code:

dictionaryId.updatePageName("FirstPageNew");

Then we are exposed to a lack of dictionary value when we retrieve from the cache again because our Key has become inconsistent. The cacheable mechanism will try to get another record from the database and that would be unforeseen by us.

Additionally, if the class of our returned object from Cache was not immutable then it could return different responses on each cache call. For example the first person executes a request with a specified Key to retrieve a dictionary and modifies this dictionary value then the second person with another request and the same Key will see different value. These situations could happen in Memory Caching for example in the EhCache implementation. If we had use distributed cache like Hazelcast, there would be no problem in returned value, but it depends on the configuration.

Disadvantages of Immutable Objects

  • Every update of object gets involved to create a new one and that leads to increased memory consumption but current Garbage Collectors should deal with it.
  • All fields should be initialized at the beginning of the object’s life but we can always use the Builder Pattern instead of Constructor.

Conclusions

The Immutable Object is a good practice in daily application programming because make it makes working and tracking bugs. It’s very simple to create and use in many different cases.

Meet the geek-tastic people, and allow us to amaze you with what it's like to work with j‑labs!

Contact us