Learn How to Make Java Classes More Consistent with Minimal Effort

Written by artemsutulov | Published 2022/05/13
Tech Story Tags: debugging | getsentry | monitoring | java | validation | consistency | classes | software-development

TLDRHere I will show you the potential solution without some complex stuff like immutability, just a recipe that will help avoid that pain without reconsidering every aspect of writing Objects.via the TL;DR App

Throughout my experience, the vast majority of Java data classes were written the way they made many of my colleagues, including myself. Hundreds of human hours to fix mistakes that were so stupid that they shouldn’t even exist. Sometimes it was notoriously famous NullPointerExceptions, and sometimes they were related to consistency - the even agreements of parts to each other.

This is the first of two about reliable and consistent objects. Here I will show you the potential solution without some complex stuff like immutability, just a recipe that will help avoid that pain without reconsidering every aspect of writing Objects.

The problem

If we make a simple serializable object which is pretty simple and doesn’t modify at all and has no meaning in business logic, we have no problems. But if you make, for example, database representation objects you can have some problems.

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 ACTIVE, the email must be filled.

public class Account {

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

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

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public AccountStatus getStatus() {
        return status;
    }

    public void setStatus(AccountStatus status) {
        this.status = status;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

}

And an enum for status the field.

public enum AccountStatus {
    CREATED,
    VERIFIED,
    ACTIVE
}

Throughout the life of this object, we do not control the fields content at all. Nulls can be set to any field, or, for example, ““.

The main problem is that this class is responsible for nothing and can be used in whatever way we instantiate it. For example, here we create an instance with all null fields and have no errors:

@Test
void should_successfully_instantiate_and_validate_nothing() {
    // given
    var result = new Account(null, null, null);

    // when //then
    assertThat(result.getId()).isNull();
    assertThat(result.getEmail()).isNull();
    assertThat(result.getStatus()).isNull();
}

And here we set status ACTIVE which can’t be without an email. Eventually, we will have a lot of business logic errors because of that inconsistency, such as NullPointerException and a lot more.

@Test
void should_allow_to_set_any_state_and_any_email() {
    // given
    var account = new Account("example-id", CREATED, "");

    // when
    account.setStatus(ACTIVE);
    account.setEmail(null); // Any part of code in this project can change the class as it wants to. No consistency

    // then
    assertThat(account.getStatus()).isEqualTo(ACTIVE);
    assertThat(account.getEmail()).isBlank();
}

Solution

As you can see, it’s effortless to make a slip up when working with Accounts when the object is just a boilerplate with no consistency validations. To avoid this we can:

  1. Validate fields for null or empty and check the contract between fields. I suggest to do that in Constructors and setters.
  2. Use java.util.Optional each nullable field to avoid NPEs.
  3. Create complex mutations as methods within the responsible class. For example, to verify an Account we have a method verify so we have full control over mutation when an Account is being verified.

Here is the consistent version of the Account class, for validations I use apache commons-lang:

public class Account {

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

    public Account(String id, AccountStatus status, Optional<String> email) {
        this.id = notEmpty(id);
        this.status = notNull(status);
        this.email = checkEmail(notNull(email));
    }

    public void verify(Optional<String> email) {
        this.status = VERIFIED;
        this.email = checkEmail(email);
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = notEmpty(id);
    }

    public AccountStatus getStatus() {
        return status;
    }

    public Optional<String> getEmail() {
        return email;
    }

    public void setEmail(Optional<String> email) {
        this.email = checkEmail(email);
    }

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

}

As you can see from this test, it’s impossible to create it with empty fields or to set an empty email when status is ACTIVE.

@Test
void should_validate_parameters_on_instantiating() {
    assertThatThrownBy(() -> new Account("", CREATED, empty())).isInstanceOf(IllegalArgumentException.class);
    assertThatThrownBy(() -> new Account("example-id", null, empty())).isInstanceOf(NullPointerException.class);
    assertThatThrownBy(() -> new Account("example-id", ACTIVE, empty()))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", ACTIVE));
}

Here is the verification of the account. It validates it the same way as instantiating with the wrong status:

@Test
void should_verify_and_validate() {
    // given
    var email = "example@example.com";
    var account = new Account("example-id", CREATED, empty());

    // when
    account.verify(of(email)); // Account controls its state's consistency and won't be with the wrong data

    // then
    assertThat(account.getStatus()).isEqualTo(VERIFIED);
    assertThat(account.getEmail().get()).isEqualTo(email);
    assertThatThrownBy(
            () -> account.verify(empty()) // It's impossible to verify account without an email
    )
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", VERIFIED));

}

If you have an ACTIVE account, try to set it empty email, which is not possible and we will prevent it:

@Test
void should_fail_when_set_empty_email_for_activated_account() {
    // given
    var activatedAccount = new Account("example-id", ACTIVE, of("example@example.com"));

    // when // then
    assertThatThrownBy(() -> activatedAccount.setEmail(empty()))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage(format("Email must be filled when status %s", ACTIVE));
}

Conclusion

When writing classes that are more than serializable objects, it’s better to have some validation and consistency checks. It’s a bit more work in the beginning but will save you a lot of time and nerves in the future. To accomplish this:

  1. Create validations for each constructor and setter field.
  2. Use java.utill.Optional.
  3. Place complex mutations in a proper place - to the responsible class itself.


You can find a full working example on GitHub.


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