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.
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:
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?
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("[email protected]");// 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, "[email protected]");
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.
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.
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("[email protected]");
Demonstration of that in a unit test:
@Test
void should_successfully_change_email() {
// given
var account = account()
.id("example-id")
.status(VERIFIED)
.email(of("[email protected]"))
.build();
var newEmail = "[email protected]";
// 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 = "[email protected]";
// when
var verified = created.verify(email);
// then
assertThat(verified.getId()).isEqualTo(created.getId());
assertThat(verified.getStatus()).isEqualTo(VERIFIED);
assertThat(verified.getEmail().get()).isEqualTo(email);
}
Living with immutable, consistent, and reliable objects is harsh, but in the proper way, it can become a lot easier.
When implementing one,
copy
method inside the responsible class that returns a Builder
, and use it to create new instances inside your class.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