Learn How to Live with Immutable and Reliable Objects in Java

Written by artemsutulov | Published 2022/05/28
Tech Story Tags: debugging | getsentry | monitoring | java | immutability | unit-testing | hashmap | hackernoon-top-story

TLDRWhen writing complex projects, it’s essential to develop a good code culture. Use of immutable and consistent objects is one of the most important ones. You can neglect this and write complex objects as standard ones, but it will be a source of bugs when the project grows enough. In this article, I’ll show how to make objects immutable and make them eloquently and efficiently. The default way won’t have setters with an object with no setters. The only way to do that is to create a new instance and place new values to the responsible class itself.via the TL;DR App

When writing complex projects, it’s essential to develop a good code culture. Usage of immutable and consistent objects is one of the most important ones.

You can neglect this and write complex objects as standard ones, but it will be a significant source of bugs when the project grows enough.

In my previous article, I’ve shown how we can improve the consistency and reliability of standard objects. In a few words:

  • Add validations when setting values

  • Use java.util.Optional for each nullable field

  • Place complex mutations in a proper place - to the responsible class itself.

But those actions are not enough to make fully reliable objects. In this article, I’ll show how to make objects immutable and make them eloquently and efficiently.

The Problem

If we make a simple serializable object with default constructor/getters/setters, it’s OK to make it the standard way. But let’s assume we write something more complex. And most likely, it’s used throughout countless places.

For example, it’s used in HashMaps and maybe in a multi-threading environment. So, it doesn’t seem like a good idea anymore - to write it the default way. Breaking HashMap is not a big deal, and an inconsistent state between threads won’t make wait long.

The first things that come to mind when thinking about making immutable objects are:

  1. Do not make setter methods
  2. Make all fields final
  3. Don’t share instances to mutable objects
  4. Do not allow subclasses to override methods (I will omit it in this article)

But how to live with that kind of object? When we need to change it, we need to make a copy; how can we do it well without copy-pasting code and logic every time?

A few words about our example classes

Let’s say we have Accounts. Each account has anid, status, and email. Accounts can be verified via email. When the status is CREATED, we do not expect the email to be filled. But when it is VERIFIED or INACTIVE, the email must be filled.

public enum AccountStatus {
    CREATED,
    VERIFIED,
    INACTIVE
}

The canon Account.java implementation:

public class Account {

    private final String id;
    private final AccountStatus status;
    private final String email;

    public Account(String id, AccountStatus status, String email) {
        this.id = id;
        this.status = status;
        this.email = email;
    }

    // equals / hashCode / getters

}

Let’s imagine we’ve created an account. Then, somewhere in business logic, we need to change an email.

var account = new Account("some-id", CREATED, null);

How can we do that? The default way won’t work, we can’t have setters with an immutable class.

account.setEmail("example@example.com");// we can not do that, we have no setters

The only way to do that is to create a new instance and place to constructor previous values:

var withEmail = new Account(account.getId(), CREATED, "example@example.com");

But it’s not the best way to change a field’s value, it produces so much copy/paste, and the Account class isn’t responsible for its consistency.

Solution

The suggested solution is to provide mutation methods from the Account class and implement copying logic inside the responsible class. Also, it’s essential to add required validations and usage of Optional for the email field, so we won’t have NPE or consistency problems.

To build an object, I use the ‘Builder’ pattern. It’s pretty famous, and there are plenty of plugins for your IDE to generate it automatically.

public class Account {

    private final String id;
    private final AccountStatus status;
    private final Optional<String> email;

    public Account(Builder builder) {
        this.id = notEmpty(builder.id);
        this.status = notNull(builder.status);
        this.email = checkEmail(builder.email);
    }

    public Account verify(String email) {
        return copy()
                .status(VERIFIED)
                .email(of(email))
                .build();
    }

    public Account changeEmail(String email) {
        return copy()
                .email(of(email))
                .build();
    }

    public Account deactivate() {
        return copy()
                .status(INACTIVE)
                .build();
    }

    private Optional<String> checkEmail(Optional<String> email) {
        isTrue(
                notNull(email).map(StringUtils::isNotBlank).orElse(false) || this.status.equals(CREATED),
                "Email must be filled when status %s",
                this.status
        );
        return email;
    }


    public static final class Builder {

        private String id;
        private AccountStatus status;
        private Optional<String> email = empty();

        private Builder() {
        }

        public static Builder account() {
            return new Builder();
        }

        public Builder id(String id) {
            this.id = id;
            return this;
        }

        public Builder status(AccountStatus status) {
            this.status = status;
            return this;
        }

        public Builder email(Optional<String> email) {
            this.email = email;
            return this;
        }

        public Account build() {
            return new Account(this);
        }
    }

    // equals / hashCode / getters

}

As you can see, there is a private method copy in our class that returns Builder with an exact copy. That removes copy-pasting of all fields, however, it’s crucial that this method is not accessible from outside Account.java because, with that Builder outside, we lose control over the state and consistency.

Changing accounts in a new way

Now, let’s create an account:

var account = account()
        .id("example-id")
        .status(CREATED)
        .email((empty())
        .build();

When we need to change an email, we don’t need to be responsible for creating a copy, we simply call a method from Account itself:

var withNewEmail = account.changeEmail("new@new.com");

Demonstration of that in a unit test:

@Test
void should_successfully_change_email() {
    // given
    var account = account()
            .id("example-id")
            .status(VERIFIED)
            .email(of("old@old.com"))
            .build();
    var newEmail = "new@new.com";

    // when
    var withNewEmail = account.changeEmail(newEmail);

    // then
    assertThat(withNewEmail.getId()).isEqualTo(account.getId());
    assertThat(withNewEmail.getStatus()).isEqualTo(account.getStatus());
    assertThat(withNewEmail.getEmail()).isEqualTo(of(newEmail));
}

To verify an account, we don’t create a copy with status VERIFIED and a new email. We simply call the method verify, which not only will it create a copy for us, but will also check the validity of an email.

@Test
void should_successfully_verify_account() {
    // given
    var created = account()
            .id("example-id")
            .status(CREATED)
            .build();
    var email = "example@example.com";

    // when
    var verified = created.verify(email);

    // then
    assertThat(verified.getId()).isEqualTo(created.getId());
    assertThat(verified.getStatus()).isEqualTo(VERIFIED);
    assertThat(verified.getEmail().get()).isEqualTo(email);
}

Conclusion

Living with immutable, consistent, and reliable objects is harsh, but in the proper way, it can become a lot easier.

When implementing one, don’t forget to:

  1. Make all fields final
  2. Not provide setters
  3. Not share links to mutable objects
  4. Do not allow subclasses to override methods
  5. Provide mutation methods from your class
  6. Implement the private copy method inside the responsible class that returns a Builder, and use it to create new instances inside your class.
  7. Maintain consistency of fields by using validations on values
  8. Use Optional with nullable fields

You can find the fully working example with more unit tests on GitHub.

Lead Photo by Adam Nieścioruk on Unsplash


Written by artemsutulov | I'm a professional FullStack Software Engineer, currently working for Revolut as Software Engineer (Backend).
Published by HackerNoon on 2022/05/28