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.
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();
}
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:
Constructors
and setters
.java.util.Optional
each nullable field to avoid NPEs.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 = "[email protected]";
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("[email protected]"));
// when // then
assertThatThrownBy(() -> activatedAccount.setEmail(empty()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(format("Email must be filled when status %s", ACTIVE));
}
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:
java.utill.Optional
.
You can find a full working example on GitHub.