Ao escrever projetos complexos, é essencial desenvolver uma boa cultura de código. O uso de objetos imutáveis e consistentes é um dos mais importantes.
Você pode negligenciar isso e escrever objetos complexos como padrão, mas será uma fonte significativa de bugs quando o projeto crescer o suficiente.
Em meuartigo anterior, mostrei como podemos melhorar a consistência e a confiabilidade de objetos padrão. Em poucas palavras:
Adicionar validações ao definir valores
Use java.util.Optional
para cada campo anulável
Coloque as mutações complexas em um local apropriado - para a própria classe responsável.
Mas essas ações não são suficientes para criar objetos totalmente confiáveis. Neste artigo, mostrarei como tornar objetos imutáveis de maneira eloquente e eficiente.
Se fizermos um objeto serializável simples com construtor/getters/setters padrão, não há problema em torná-lo da maneira padrão. Mas vamos supor que escrevemos algo mais complexo. E muito provavelmente, é usado em inúmeros lugares.
Por exemplo, é usado em HashMaps e talvez em um ambiente multi-threading. Portanto, não parece mais uma boa ideia - escrevê-lo da maneira padrão. Quebrar o HashMap não é grande coisa, e um estado inconsistente entre as threads não vai demorar muito.
As primeiras coisas que vêm à mente quando se pensa em fazer objetos imutáveis são:
Mas como conviver com esse tipo de objeto? Quando precisamos alterá-lo, precisamos fazer uma cópia; como podemos fazer isso bem sem copiar e colar código e lógica toda vez?
Digamos que temos contas. Cada conta tem um id
, status
e e- email
. As contas podem ser verificadas por e-mail. Quando o status é CREATED
, não esperamos que o e-mail seja preenchido. Mas quando for VERIFIED
ou INACTIVE
, o e-mail deve ser preenchido.
public enum AccountStatus { CREATED, VERIFIED, INACTIVE }
A implementação canon Account.java
:
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 }
Vamos imaginar que criamos uma conta. Então, em algum lugar da lógica de negócios, precisamos alterar um e-mail.
var account = new Account("some-id", CREATED, null);
Como podemos fazer isso? A maneira padrão não funcionará, não podemos ter setters com uma classe imutável.
account.setEmail("[email protected]");// we can not do that, we have no setters
A única maneira de fazer isso é criar uma nova instância e colocar os valores anteriores do construtor:
var withEmail = new Account(account.getId(), CREATED, "[email protected]");
Mas não é a melhor maneira de alterar o valor de um campo, isso produz muito copiar/colar, e a classe Account não é responsável por sua consistência.
A solução sugerida é fornecer métodos de mutação da classe Account e implementar a lógica de cópia dentro da classe responsável. Além disso, é essencial adicionar as validações necessárias e o uso de Opcional para o campo de email, para que não tenhamos NPE ou problemas de consistência.
Para construir um objeto, eu uso o padrão 'Builder'. É bem famoso, e tem vários plugins para sua IDE gerar automaticamente.
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 }
Como você pode ver, existe uma copy
de método privado em nossa classe que retorna o Builder
com uma cópia exata. Isso remove o copy-pasting de todos os campos, no entanto, é crucial que esse método não seja acessível de fora do Account.java
porque, com esse Builder fora, perdemos o controle sobre o estado e a consistência.
Agora, vamos criar uma conta:
var account = account() .id("example-id") .status(CREATED) .email((empty()) .build();
Quando precisamos alterar um e-mail, não precisamos nos responsabilizar por criar uma cópia, basta chamar um método da própria Account
:
var withNewEmail = account.changeEmail("[email protected]");
Demonstração disso em um teste de unidade:
@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)); }
Para verificar uma conta, não criamos uma cópia com status VERIFIED
e um novo e-mail. Simplesmente chamamos o método verify
, que não apenas criará uma cópia para nós, mas também verificará a validade de um e-mail.
@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); }
Viver com objetos imutáveis, consistentes e confiáveis é difícil, mas da maneira correta pode se tornar muito mais fácil.
Ao implementar um,
copy
dentro da classe responsável que retorna um Builder
e use-o para criar novas instâncias dentro de sua classe.Optional
com campos anuláveis
Você pode encontrar o exemplo totalmente funcional com mais testes de unidade no GitHub .
Foto principal de Adam Nieścioruk no Unsplash