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:
- Do not make setter methods
- Make all fields final
- Donât share instances to mutable objects
- 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,
- Make all fields final
- Not provide setters
- Not share links to mutable objects
- Do not allow subclasses to override methods
- Provide mutation methods from your class
- Implement the
private copy
method inside the responsible class that returns aBuilder
, and use it to create new instances inside your class. - Maintain consistency of fields by using validations on values
- 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