Descomplicando Arquitetura Limpa (clean architecture) com Spring boot
Gervásio Artur
Posted on August 13, 2024
Introdução
A Arquitetura Limpa (Clean Architecture), proposta por Robert C. Martin, é uma abordagem poderosa e flexível para o desenvolvimento de software que visa criar sistemas robustos, testáveis e fáceis de manter. No contexto de aplicações Java com Spring Boot, a implementação da Arquitetura Limpa pode parecer complexa, especialmente para desenvolvedores que estão começando a explorá-la. No entanto, quando entendida e aplicada corretamente, ela simplifica a construção de aplicações, separando claramente as responsabilidades e promovendo um código mais organizado e modular. Este artigo vai descomplicar os conceitos fundamentais da Arquitetura Limpa e mostrar como integrá-los de forma prática e eficiente em projetos Spring Boot, permitindo que você aproveite ao máximo os benefícios dessa arquitetura.
O Conceito de Arquitetura Limpa
A Arquitetura Limpa, proposta por Robert C. Martin (também conhecido como Uncle Bob), é um estilo de design de software que busca separar o código em camadas distintas para promover uma maior flexibilidade, manutenção e testabilidade. O conceito central é isolar a lógica de negócios da lógica de implementação e da infraestrutura.
Principais Conceitos:
-
Separação de Responsabilidades: Divide o sistema em camadas com responsabilidades distintas, permitindo que alterações em uma camada não afetem outras. As camadas principais incluem:
- Entidades: Regras de negócio e lógica fundamental.
- Casos de Uso: Implementação da lógica de aplicação e orquestração dos processos.
- Interface e Adaptadores: Comunicação com o mundo externo, como APIs e banco de dados.
- Infraestrutura: Implementações concretas, como frameworks e bibliotecas.
- Dependências Direcionais: As dependências devem sempre apontar para o núcleo da aplicação (as regras de negócio), e não o contrário. Isso significa que as camadas externas podem depender das internas, mas não o inverso.
- Independência de Frameworks e Tecnologias: O design não deve ser atrelado a um framework ou tecnologia específica, facilitando a troca de tecnologias e frameworks sem alterar a lógica de negócios.
- Testabilidade: Facilita a criação de testes unitários e de integração, uma vez que a lógica de negócios está isolada das dependências externas.
A Arquitetura Limpa promove um código mais modular e sustentável, facilitando a manutenção e evolução do sistema.
Implementação com Spring Boot
Na fase inicial, vamos configurar o projeto de forma estratégica. Embora muitas abordagens comuns envolvam a criação de pacotes que representam cada camada da Clean Architecture, essa prática pode não ser a mais assertiva. Um dos princípios fundamentais da Clean Architecture é manter as camadas mais internas isoladas das camadas mais externas.
Para garantir essa separação, construiremos nossa aplicação em módulos, onde cada módulo representará uma camada da Clean Architecture. Usaremos o Maven como gerenciador de dependências e o JUnit para a criação dos nossos testes, assegurando que a aplicação seja modular e facilmente testável.
Baseado no diagrama da Clean Architecture, vamos criar os seguintes módulos para a nossa aplicação:
- core: Representando a camada mais interna, que corresponde às entities do diagrama.
- usecase: Representando a camada de use cases do diagrama.
- application: Representando a camada de interface adapters do diagrama.
- infra: Representando a camada de frameworks e drivers do diagrama.
Com isso, a estrutura dos nossos módulos ficará assim:
Agora que definimos nossos modulos , vamos partir para o código.
Vamos definir o arquivo pom.xml
principal do projeto. Neste arquivo, vamos especificar a versão do Java que será utilizada, listar os módulos que compõem o projeto e configurar o plugin JaCoCo para a cobertura de testes. A estrutura organizada do pom.xml
ficará assim:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<name>walletwise-api</name>
<description>walletwise-api</description>
<properties>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<spring.boot.version>3.3.2</spring.boot.version>
</properties>
<modules>
<module>core</module>
<module>application</module>
<module>usecase</module>
<module>infra</module>
<module>coverage</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
<release>${maven.compiler.release}</release>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<id>jacoco-initialize</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>jacoco-report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>**/Abstract*</exclude>
<exclude>**/BaseEntity*</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Para o módulo core, que é o mais interno e central na arquitetura, manteremos as dependências mínimas. A única dependência necessária será o JUnit, que utilizaremos para escrever e executar os testes unitários. O arquivo pom.xml para o módulo core ficará organizado da seguinte forma:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>core</artifactId>
<name>core</name>
<description>core</description>
<dependencies>
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>0.15</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
O módulo usecase terá como única dependência o módulo core. Isso garante que os casos de uso possam acessar as entidades e regras de negócio definidas no core, mantendo a separação de responsabilidades. O arquivo pom.xml para o módulo usecase será organizado da seguinte forma:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>usecase</artifactId>
<name>usecase</name>
<description>usecase</description>
<dependencies>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>core</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
O módulo application terá dependências nos módulos core e usecase, além do JUnit e outras dependências auxiliares para a escrita de testes. Isso permitirá que a camada de interface adapte os casos de uso para a comunicação com o mundo externo (APIs, interfaces de usuário, etc.), ao mesmo tempo que fornece as ferramentas necessárias para testar esses componentes. O arquivo pom.xml para o módulo application será organizado da seguinte forma:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>application</artifactId>
<name>application</name>
<description>application</description>
<dependencies>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>core</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>usecase</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>0.15</version>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
O módulo infra é responsável por implementar os detalhes de infraestrutura, como frameworks e drivers, necessários para a aplicação funcionar corretamente. Sendo o módulo mais externo na arquitetura limpa, ele depende dos módulos core, usecase, e application. Também inclui várias dependências para o Spring Boot, banco de dados, segurança, migrações de banco de dados, e testes. O pom.xml para o módulo infra será organizado da seguinte forma:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>infra</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>infra</name>
<description>infra</description>
<properties>
<jwt.verion>0.11.5</jwt.verion>
<sql.version>3.46.0.0</sql.version>
<springdoc.version>2.0.4</springdoc.version>
<application.version>0.0.1-SNAPSHOT</application.version>
</properties>
<dependencies>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>core</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>usecase</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>application</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Vamos adicionar mais um módulo ao projeto, chamado coverage, que será responsável por gerar e apresentar a cobertura dos nossos testes. Este módulo terá as seguintes dependências: core, usecase, application e infra.
Além disso, o módulo coverage incluirá o plugin JaCoCo para calcular a cobertura dos testes.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.br.walletwise</groupId>
<artifactId>walletwise-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>coverage</artifactId>
<dependencies>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>core</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>usecase</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>application</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.br.walletwise</groupId>
<artifactId>infra</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.10</version>
<executions>
<execution>
<id>jacoco-site-aggregate</id>
<phase>verify</phase>
<goals>
<goal>report-aggregate</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Aqui está a estrutura de diretórios do projeto, representando todos os módulos:
walletwise-api/
│
├── core/
│ ├── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── br/
│ │ │ └── core/
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── br/
│ │ └── core/
│ └── pom.xml
│
├── usecase/
│ ├── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── br/
│ │ │ └── usecase/
│ └── pom.xml
│
├── application/
│ ├── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── br/
│ │ │ └── application/
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── br/
│ │ └── application/
│ └── pom.xml
│
├── infra/
│ ├── src/
│ │ ├── main/
│ │ │ └── java/
│ │ │ └── com/
│ │ │ └── br/
│ │ │ └── infra/
│ │ └── test/
│ │ └── java/
│ │ └── com/
│ │ └── br/
│ │ └── infra/
│ └── pom.xml
│
├── coverage/
│ └── pom.xml
│
└── pom.xml
Cenário
Agora que estruturamos o nosso projeto, vamos definir um cenário fictício para desenvolver nossos casos de uso.
Recebemos a responsabilidade de desenvolver uma funcionalidade para o cadastro de usuários em um sistema de gerenciamento de finanças pessoais. Os dados a serem armazenados incluem: firstname
, lastname
, username
, email
e password
.
O sistema deve garantir que o usuário seja criado com email
e username
únicos.
Desenvolvimento da Camada core
Vamos começar com a camada mais interna (o core). Neste caso, iremos criar a entidade User
, que conterá os campos mencionados anteriormente, além de alguns adicionais para atender às nossas regras de negócio.
package com.br.walletwise.core.domain.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class User {
private UUID id;
private String firstname;
private String lastname;
private String username;
private String email;
private String password;
private boolean active;
public User(UUID id, String firstname, String lastname, String username, String email, String password, boolean active) {
this.id = id;
this.firstname = firstname;
this.lastname = lastname;
this.username = username;
this.email = email;
this.password = password;
this.active = active;
}
public User(String firstname, String lastname, String username, String email, String password) {
this.firstname = firstname;
this.lastname = lastname;
this.username = username;
this.email = email;
this.password = password;
}
public UUID getId() {
return id;
}
public String getFirstname() {
return firstname;
}
public void setFirstname(String firstname) {
this.firstname = firstname;
}
public String getLastname() {
return lastname;
}
public void setLastname(String lastname) {
this.lastname = lastname;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
}
Nossa entidade já contém os campos necessários para atender à regra de negócio. No entanto, utilizaremos DDD (Domain-Driven Design) para garantir a consistência dos dados durante a criação ou modificação do objeto. Para isso, faremos uso de validadores.
Primeiro, vamos definir o contrato para a validação:
package com.br.walletwise.core.validation.validator.contract;
public interface Validator {
String validate();
}
Em seguida, vamos criar o nosso ValidationComposite
.
O ValidationComposite
é uma estrutura que permite combinar vários validadores em um único objeto. Ele facilita a aplicação de múltiplas regras de validação de forma organizada e eficiente. O ValidationComposite
iterará por todos os validadores definidos, aplicando cada um deles à entidade. Se algum validador falhar, o ValidationComposite
retornará o erro correspondente, garantindo assim a consistência dos dados.
package com.br.walletwise.core.validation;
import com.br.walletwise.core.validation.validator.contract.Validator;
import java.util.List;
public class ValidationComposite implements Validator {
private final List<Validator> validators;
public ValidationComposite(List<Validator> validators) {
this.validators = validators;
}
@Override
public String validate() {
for (Validator validator : validators) {
String error = validator.validate();
if (error != null) {
return error;
}
}
return null;
}
}
O próximo passo é criar o ValidationBuilder
.
O ValidationBuilder
é uma classe que facilita a criação de validadores de forma fluida e estruturada. Ele permite construir uma sequência de validações de maneira encadeada, simplificando a aplicação de múltiplas regras em um campo específico da entidade. Com o ValidationBuilder
, podemos facilmente adicionar diferentes tipos de validação, como obrigatoriedade, formato de email, e formato de nome de usuario, garantindo que todas as regras necessárias sejam aplicadas de maneira consistente.
package com.br.walletwise.core.validation;
import com.br.walletwise.core.validation.validator.EmailValidator;
import com.br.walletwise.core.validation.validator.RequiredFieldValidator;
import com.br.walletwise.core.validation.validator.UsernameValidator;
import com.br.walletwise.core.validation.validator.contract.Validator;
import java.util.ArrayList;
import java.util.List;
public class ValidationBuilder {
private final List<Validator> validators = new ArrayList<>();
private final String fieldName;
private final Object fieldValue;
private ValidationBuilder(String fieldName, Object fieldValue) {
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public static ValidationBuilder of(String fieldName, Object fieldValue) {
return new ValidationBuilder(fieldName, fieldValue);
}
public ValidationBuilder required() {
this.validators.add(new RequiredFieldValidator(this.fieldName, this.fieldValue));
return this;
}
public ValidationBuilder username() {
this.validators.add(new UsernameValidator(this.fieldValue));
return this;
}
public ValidationBuilder email() {
this.validators.add(new EmailValidator(this.fieldValue));
return this;
}
public List<Validator> build() {
return this.validators;
}
}
Por fim, vamos criar os nossos validadores propriamente ditos.
Os validadores são responsáveis por verificar se os dados de um campo específico da entidade atendem a determinadas regras. Cada validador implementa uma regra específica, como garantir que um campo não esteja vazio, que um email esteja no formato correto, ou que um username siga um padrão estabelecido. Ao aplicar esses validadores, asseguramos que os dados estejam consistentes e válidos antes de serem persistidos ou utilizados em outras operações dentro da aplicação.
AbstractValidator
fornece uma estrutura comum para todos os validadores, centralizando a lógica básica, enquanto deixa a implementação específica da validação para as classes derivadas.
package com.br.walletwise.core.validation.validator;
import com.br.walletwise.core.validation.validator.contract.Validator;
public abstract class AbstractValidator implements Validator {
protected String fieldName;
protected Object fieldValue;
@Override
public String validate() {
return null;
}
}
O RequiredFieldValidator é um validador que garante que campos obrigatórios não sejam deixados em branco ou nulos. Ele é flexível o suficiente para lidar com strings e outros tipos de dados, retornando uma mensagem de erro clara se a validação falhar.
package com.br.walletwise.core.validation.validator;
import com.br.walletwise.core.validation.AbstractValidator;
public class RequiredFieldValidator extends AbstractValidator {
private final String returnMessage;
public RequiredFieldValidator(String fieldName, Object fieldValue) {
this.fieldName = fieldName;
this.fieldValue = fieldValue;
this.returnMessage = this.fieldName + " is required.";
}
@Override
public String validate() {
return switch (this.fieldValue) {
case String s -> s.trim().isEmpty() ? this.returnMessage : null;
case null -> this.returnMessage;
default -> null;
};
}
}
O EmailValidator verifica se o valor de um campo corresponde a um e-mail válido usando uma expressão regular. Se o valor não for um e-mail válido, ele retorna uma mensagem de erro. Caso contrário, a validação passa sem erros.
package com.br.walletwise.core.validation.validator;
import com.br.walletwise.core.domain.model.GeneralEnumString;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class EmailValidator extends AbstractValidator {
private final String returnMessage;
public EmailValidator(Object fieldValue) {
this.fieldValue = fieldValue;
this.returnMessage = "E-mail is invalid.";
}
@Override
public String validate() {
String value = (String) this.fieldValue;
value = value.trim();
String regex = GeneralEnumString.EMAIL_REGEX_EXPRESSION.getValue();
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(value);
if (!matcher.matches()) return this.returnMessage;
return null;
}
}
O UsernameValidator garante que o nome de usuário não contenha o caractere @, que é reservado para endereços de e-mail. Se o nome de usuário contiver esse caractere, a validação falha e uma mensagem de erro é retornada. Caso contrário, a validação passa sem erros.
package com.br.walletwise.core.validation.validator;
public class UsernameValidator extends AbstractValidator {
private final String returnMessage;
public UsernameValidator(Object fieldValue) {
this.fieldValue = fieldValue;
this.returnMessage = "Username is invalid.";
}
@Override
public String validate() {
String username = (String) fieldValue;
if (username.contains("@"))
return returnMessage;
return null;
}
}
Agora, vamos definir uma exceção padrão que será lançada caso algum dos nossos campos não respeite as regras de negócio estabelecidas.
A exceção personalizada DomainException
será utilizada para sinalizar que houve uma violação das regras de validação dentro do domínio da aplicação. Esta exceção será lançada quando um campo não atender aos critérios definidos pelos validadores.
package com.br.walletwise.core.exception;
public class DomainException extends RuntimeException {
public DomainException(String message) {
super(message);
}
}
Agora que temos os validadores e a exceção padronizada definidos, vamos prosseguir criando uma abstração para as nossas entidades. Essa abstração permitirá que todas as entidades que sejam criadas e estendidas a partir dessa classe base possam ter acesso ao método de validação do ValidationComposite.
package com.br.walletwise.core.domain.entity;
import com.br.walletwise.core.validation.ValidationComposite;
import com.br.walletwise.core.validation.validator.contract.Validator;
import java.util.List;
public class AbstractEntity {
protected List<Validator> buildValidators() {
return List.of();
}
protected String validate() {
List<Validator> validators = this.buildValidators();
return new ValidationComposite(validators).validate();
}
}
Vamos atualizar a nossa entidade User para herdar da AbstractEntity e utilizar o método validate() para garantir que as regras de validação sejam aplicadas corretamente.
package com.br.walletwise.core.domain.entity;
import com.br.walletwise.core.exception.DomainException;
import com.br.walletwise.core.validation.ValidationBuilder;
import com.br.walletwise.core.validation.validator.contract.Validator;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class User extends AbstractEntity {
private UUID id;
private String firstname;
private String lastname;
private String username;
private String email;
private String password;
private boolean active;
public User(UUID id, String firstname, String lastname, String username, String email, String password, boolean active) {
this.id = id;
this.firstname = firstname;
this.lastname = lastname;
this.username = username;
this.email = email;
this.password = password;
this.active = active;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public User(String firstname, String lastname, String username, String email, String password) {
this.firstname = firstname;
this.lastname = lastname;
this.username = username;
this.email = email;
this.password = password;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public UUID getId() {
return id;
}
public String getFirstname() {
return firstname;
}
public void setFirstname(String firstname) {
this.firstname = firstname;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public String getLastname() {
return lastname;
}
public void setLastname(String lastname) {
this.lastname = lastname;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
String error = this.validate();
if (error != null) throw new DomainException(error);
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
@Override
protected List<Validator> buildValidators() {
List<Validator> validators = new ArrayList<>();
validators.addAll(ValidationBuilder.of("Firstname", this.firstname).required().build());
validators.addAll(ValidationBuilder.of("Lastname", this.lastname).required().build());
validators.addAll(ValidationBuilder.of("Username", this.username).required().username().build());
validators.addAll(ValidationBuilder.of("E-mail", this.email).required().email().build());
validators.addAll(ValidationBuilder.of("Password", this.password).required().build());
return validators;
}
}
Aqui está um exemplo de como você pode organizar a classe de testes para a entidade User, utilizando JUnit e o Maven:
package com.br.walletwise.core.entity;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.core.exception.DomainException;
import com.github.javafaker.Faker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
class UserTests {
private final Faker faker = new Faker();
String strongPassword = "Password!1234H";
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to build user with firstname empty or null")
void shouldThrowDomainExceptionWhenTryingToBuildUserWithFirstNameEmptyOrNull(String firstname) {
Throwable exception = catchThrowable(() -> new User(
null,
firstname,
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Firstname is required.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to set firstname with empty or null value")
void shouldThrowDomainExceptionWhenTryingToSetFirstNameWithEmptyOrNullValue(String firstname) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setFirstname(firstname));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Firstname is required.");
}
@Test
@DisplayName("Should return null when trying to set firstname with valid input")
void shouldReturnNullWhenTryingToSetFirstNameWithValidInput() {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
String updateField = faker.name().firstName();
user.setFirstname(updateField);
assertThat(user.getFirstname()).isEqualTo(updateField);
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to build user with lastname empty or null")
void shouldThrowDomainExceptionWhenTryingToBuildUserWitLastNameEmptyOrNull(String lastname) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
lastname,
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Lastname is required.");
}
@Test
@DisplayName("Should return null when trying to set Lastname with valid input")
void shouldReturnNullWhenTryingToSetLastNameWithValidInput() {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
String updateField = faker.name().lastName();
user.setLastname(updateField);
assertThat(user.getLastname()).isEqualTo(updateField);
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to set lastname with empty or null value")
void shouldThrowDomainExceptionWhenTryingToSetLastnameWithEmptyOrNullValue(String lastname) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setLastname(lastname));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Lastname is required.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to build user with username empty or null")
void shouldThrowDomainExceptionWhenTryingToBuildUserWitUsernameEmptyOrNull(String username) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
faker.name().lastName(),
username,
faker.internet().emailAddress(),
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Username is required.");
}
@Test
@DisplayName("Should return null when trying to set username with valid input")
void shouldReturnNullWhenTryingToSetUsernameWithValidInput() {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
String updateField = faker.name().username();
user.setUsername(updateField);
assertThat(user.getUsername()).isEqualTo(updateField);
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to set Username with empty or null value")
void shouldThrowDomainExceptionWhenTryingToSetUsernameWithEmptyOrNullValue(String Username) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setUsername(Username));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Username is required.");
}
@ParameterizedTest
@ValueSource(strings = {"@any_username", "any@username", "any_username@"})
@DisplayName("Should throw DomainException when trying to build user with invalid Username")
void shouldThrowDomainExceptionWhenTryingToBuildUserWithInvalidUsername(String username) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
faker.name().lastName(),
username,
faker.internet().emailAddress(),
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Username is invalid.");
}
@ParameterizedTest
@ValueSource(strings = {"@any_username", "any@username", "any_username@"})
@DisplayName("Should throw DomainException when trying to update user with invalid Username")
void shouldThrowDomainExceptionWhenTryingToUpdateUserWithInvalidUsername(String username) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setUsername(username));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Username is invalid.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to build user with email empty or null")
void shouldThrowDomainExceptionWhenTryingToBuildUserWitEmailEmptyOrNull(String email) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
email,
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("E-mail is required.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to update user with email empty or null")
void shouldThrowDomainExceptionWhenTryingToUpdateUserWitEmailEmptyOrNull(String email) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setEmail(email));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("E-mail is required.");
}
@Test
@DisplayName("Should return null when trying to set Email with valid input")
void shouldReturnNullWhenTryingToSetEmailWithValidInput() {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
String updateField = faker.internet().emailAddress();
user.setEmail(updateField);
assertThat(user.getEmail()).isEqualTo(updateField);
}
@ParameterizedTest
@ValueSource(strings = {"any_email", "@email.com", "any_@emal"})
@DisplayName("Should throw DomainException when trying to build user with invalid email")
void shouldThrowDomainExceptionWhenTryingToBuildUserWitInvalidEmail(String email) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
email,
strongPassword,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("E-mail is invalid.");
}
@ParameterizedTest
@ValueSource(strings = {"any_email", "@email.com", "any_@emal"})
@DisplayName("Should throw DomainException when trying to update user with invalid email")
void shouldThrowDomainExceptionWhenTryingToUpdateUserWitInvalidEmail(String email) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setEmail(email));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("E-mail is invalid.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to build user with password empty or null")
void shouldThrowDomainExceptionWhenTryingToBuildUserWithPasswordEmptyOrNull(String password) {
Throwable exception = catchThrowable(() -> new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
password,
true));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Password is required.");
}
@ParameterizedTest
@NullAndEmptySource
@DisplayName("Should throw DomainException when trying to update user with password empty or null")
void shouldThrowDomainExceptionWhenTryingToUpdateUserWithPasswordEmptyOrNull(String password) {
User user = new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
strongPassword,
true);
Throwable exception = catchThrowable(() -> user.setPassword(password));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Password is required.");
}
@Test
@DisplayName("Should return correct values on build success with id")
void shouldReturnCorrectValuesOnBuildSuccessWithId() {
UUID id = UUID.randomUUID();
String firstName = faker.name().firstName();
String lastName = faker.name().lastName();
String username = faker.name().username();
String email = faker.internet().emailAddress();
String password = strongPassword;
boolean isActive = true;
User user = new User(id, firstName, lastName, username, email, password, isActive);
assertThat(user.getId()).isEqualTo(id);
assertThat(user.getFirstname()).isEqualTo(firstName);
assertThat(user.getLastname()).isEqualTo(lastName);
assertThat(user.getUsername()).isEqualTo(username);
assertThat(user.getEmail()).isEqualTo(email);
assertThat(user.getPassword()).isEqualTo(password);
assertThat(user.isActive()).isEqualTo(isActive);
}
@Test
@DisplayName("Should return correct values on build success")
void shouldReturnCorrectValuesOnBuildSuccessWithNoId() {
String firstName = faker.name().firstName();
String lastName = faker.name().lastName();
String username = faker.name().username();
String email = faker.internet().emailAddress();
String password = strongPassword;
User user = new User(firstName, lastName, username, email, password);
assertThat(user.getId()).isNull();
assertThat(user.getFirstname()).isEqualTo(firstName);
assertThat(user.getLastname()).isEqualTo(lastName);
assertThat(user.getUsername()).isEqualTo(username);
assertThat(user.getEmail()).isEqualTo(email);
assertThat(user.getPassword()).isEqualTo(password);
assertThat(user.isActive()).isFalse();
}
@Test
@DisplayName("Should throw Domain exception on build failure")
void shouldThrowDomainExceptionOnBuildFailure() {
String lastName = faker.name().lastName();
String username = faker.name().username();
String email = faker.internet().emailAddress();
String password = strongPassword;
Throwable exception = catchThrowable(() -> new User("", lastName, username, email, password));
assertThat(exception).isInstanceOf(DomainException.class);
assertThat(exception.getMessage()).isEqualTo("Firstname is required.");
}
@Test
@DisplayName("Should return correct values on active success")
void shouldReturnCorrectValuesOnUpdateIdAndActiveSuccess() {
String firstName = faker.name().firstName();
String lastName = faker.name().lastName();
String username = faker.name().username();
String email = faker.internet().emailAddress();
String password = strongPassword;
boolean active = true;
User user = new User(firstName, lastName, username, email, password);
user.setActive(active);
assertThat(user.getFirstname()).isEqualTo(firstName);
assertThat(user.getLastname()).isEqualTo(lastName);
assertThat(user.getUsername()).isEqualTo(username);
assertThat(user.getEmail()).isEqualTo(email);
assertThat(user.getPassword()).isEqualTo(password);
assertThat(user.isActive()).isTrue();
}
}
Finalizamos a camada core. A estrutura de diretórios ficará assim:
core/
├── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── br/
│ │ └── walletwise/
│ │ └── core/
│ │ ├── domain/
│ │ │ └── entity/
| | | └── AbstractEntity.java
│ │ │ └── User.java
│ │ ├── exception/
│ │ │ └── DomainException.java
│ │ └── validation/
│ │ ├── AbstractValidator.java
│ │ ├── EmailValidator.java
│ │ ├── RequiredFieldValidator.java
│ │ └── UsernameValidator.java
│ └── test/
│ └── java/
│ └── com/
│ └── br/
│ └── walletwise/
│ └── core/
│ └── domain/
│ └── entity/
│ └── UserTests.java
└── pom.xml
Desenvolvimento da Camada usecase
Com a camada de core
concluída, avançamos para o desenvolvimento da camada de usecase
. Além do caso de uso CreateUser
, que é responsável pela criação de novos usuários, também implementaremos os casos de uso FindByEmail
e FindByUsername
. Esses casos de uso ajudarão a verificar se um email ou um nome de usuário já está em uso, garantindo que cada valor seja único na aplicação, conforme definido no cenário.
CreateUser.java
package com.br.walletwise.usecase;
import com.br.walletwise.core.domain.entity.User;
public interface CreateUser {
void create(User user);
}
FindByEmail.java
package com.br.walletwise.usecase;
import com.br.walletwise.core.domain.entity.User;
import java.util.Optional;
public interface FindByEmail {
Optional<User> find(String email);
}
FindByUsername.java
package com.br.walletwise.usecase;
import com.br.walletwise.core.domain.entity.User;
import java.util.Optional;
public interface FindByUsername {
Optional<User> find(String username);
}
Além disso, incluiremos o EncodePassword, que será responsável por criptografar a senha do usuário antes de persistirmos no banco de dados. Isso garantirá que as senhas sejam armazenadas de forma segura, alinhando-se às melhores práticas de segurança. Esses componentes são fundamentais para assegurar a integridade e a segurança dos dados dos usuários.
package com.br.walletwise.usecase;
public interface EncodePassword {
String encode(String password);
}
A estrutura dos diretórios para a camada usecase ficará assim:
usecase/
├── src/
│ ├── main/
│ │ └── java/
│ │ └── com/
│ │ └── br/
│ │ └── walletwise/
│ │ └── usecase/
│ │ ├── CreateUser.java
│ │ ├── FindByEmail.java
│ │ ├── FindByUsername.java
│ │ └── EncodePassword.java
└── pom.xml
Desenvolvimento da Camada application
Dando prosseguimento, iremos agora desenvolver a camada de application, que é responsável por implementar os casos de uso definidos na camada usecase e definir gateways para a comunicação com ferramentas externas.
Gateways :
FindByEmailGateway.java
package com.br.walletwise.application.gateway;
public interface EncodePasswordGateway {
String encode(String password);
}
FindByUsernameGateway.java
package com.br.walletwise.application.gateway;
import com.br.walletwise.core.domain.entity.User;
import java.util.Optional;
public interface FindByUsernameGateway {
Optional<User> find(String username);
}
EncodePasswordGateway.java
package com.br.walletwise.application.gateway;
public interface EncodePasswordGateway {
String encode(String password);
}
CreateUserGateway.java
package com.br.walletwise.application.gateway;
import com.br.walletwise.core.domain.entity.User;
public interface CreateUserGateway {
User create(User user);
}
implementações de casos de uso:
FindByUsernameImpl.java
package com.br.walletwise.application.usecasesimpl;
import com.br.walletwise.application.gateway.FindByUsernameGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.usecase.FindByUsername;
import java.util.Optional;
public class FindByUsernameImpl implements FindByUsername {
private final FindByUsernameGateway findByUsernameGateway;
public FindByUsernameImpl(FindByUsernameGateway findByUsernameGateway) {
this.findByUsernameGateway = findByUsernameGateway;
}
@Override
public Optional<User> find(String username) {
return findByUsernameGateway.find(username);
}
}
FindByEmailImpl.java
package com.br.walletwise.application.usecasesimpl;
import com.br.walletwise.application.gateway.FindByEmailGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.usecase.FindByEmail;
import java.util.Optional;
public class FindByEmailImpl implements FindByEmail {
private final FindByEmailGateway findByEmailGateway;
public FindByEmailImpl(FindByEmailGateway findByEmailGateway) {
this.findByEmailGateway = findByEmailGateway;
}
@Override
public Optional<User> find(String email) {
return this.findByEmailGateway.find(email);
}
}
EncodePasswordImpl.java
package com.br.walletwise.application.usecasesimpl;
import com.br.walletwise.application.gateway.EncodePasswordGateway;
import com.br.walletwise.usecase.EncodePassword;
public class EncodePasswordImpl implements EncodePassword {
private final EncodePasswordGateway encodePasswordGateway;
public EncodePasswordImpl(EncodePasswordGateway encodePasswordGateway) {
this.encodePasswordGateway = encodePasswordGateway;
}
@Override
public String encode(String password) {
return this.encodePasswordGateway.encode(password);
}
}
CreateUserImpl .java
package com.br.walletwise.application.usecasesimpl;
import com.br.walletwise.application.gateway.CreateUserGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.core.exception.ConflictException;
import com.br.walletwise.usecase.CreateUser;
import com.br.walletwise.usecase.EncodePassword;
import com.br.walletwise.usecase.FindByEmail;
import com.br.walletwise.usecase.FindByUsername;
public class CreateUserImpl implements CreateUser {
private final FindByUsername findByUsername;
private final FindByEmail findByEmail;
private final EncodePassword encodePassword;
private final CreateUserGateway createUserGateway;
public CreateUserImpl(FindByUsername findByUsername,
FindByEmail findByEmail,
EncodePassword encodePassword,
CreateUserGateway createUserGateway) {
this.findByUsername = findByUsername;
this.findByEmail = findByEmail;
this.encodePassword = encodePassword;
this.createUserGateway = createUserGateway;
}
@Override
public void create(User user) {
if (this.findByUsername.find(user.getUsername()).isPresent())
throw new ConflictException("Username already taken.");
if (this.findByEmail.find(user.getEmail()).isPresent())
throw new ConflictException("E-mail already in use.");
String encodedPassword = this.encodePassword.encode(user.getPassword());
user.setActive(true);
user.setPassword(encodedPassword);
this.createUserGateway.create(user);
}
}
Testes das implementações de casos de uso:
FindByUsernameImplTests.java
package com.br.walletwise.application.usecaseimpl;
import com.br.walletwise.application.gateway.FindByUsernameGateway;
import com.br.walletwise.application.mocks.MocksFactory;
import com.br.walletwise.application.usecasesimpl.FindByUsernameImpl;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.usecase.FindByUsername;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class FindByUsernameImplTests {
@Test
@DisplayName("Should return option of user")
void shouldReturnOptionOfUser() {
User user = MocksFactory.userWithNoIdFactory();
FindByUsernameGateway findByUsernameGateway = mock(FindByUsernameGateway.class);
FindByUsername findByUsername = new FindByUsernameImpl(findByUsernameGateway);
when(findByUsernameGateway.find(user.getUsername())).thenReturn(Optional.of(user));
Optional<User> userResult = findByUsername.find(user.getUsername());
assertThat(userResult).isPresent();
assertThat(userResult.get().getId()).isEqualTo(user.getId());
assertThat(userResult.get().getUsername()).isEqualTo(user.getUsername());
}
}
FindByEmailImplTests .java
package com.br.walletwise.application.usecaseimpl;
import com.br.walletwise.application.gateway.FindByEmailGateway;
import com.br.walletwise.application.mocks.MocksFactory;
import com.br.walletwise.application.usecasesimpl.FindByEmailImpl;
import com.br.walletwise.core.domain.entity.User;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FindByEmailImplTests {
@Test
@DisplayName("Should return option of user")
void shouldReturnOptionOfUser() {
User user = MocksFactory.userWithNoIdFactory();
FindByEmailGateway findByEmailGateway = mock(FindByEmailGateway.class);
FindByEmailImpl findByEmailImpl = new FindByEmailImpl(findByEmailGateway);
when(findByEmailGateway.find(user.getEmail())).thenReturn(Optional.of(user));
Optional<User> userResult = findByEmailImpl.find(user.getEmail());
assertThat(userResult).isPresent();
assertThat(userResult.get().getId()).isEqualTo(user.getId());
assertThat(userResult.get().getEmail()).isEqualTo(user.getEmail());
}
}
EncodePasswordImplTests.java
package com.br.walletwise.application.usecaseimpl;
import com.br.walletwise.application.gateway.EncodePasswordGateway;
import com.br.walletwise.application.usecasesimpl.EncodePasswordImpl;
import com.br.walletwise.usecase.EncodePassword;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class EncodePasswordImplTests {
@Test
@DisplayName("Should return option of encoded password")
void shouldReturnOptionOfEncodedPassword() {
String password = "password";
String encodedPassword = "encodedPassword";
EncodePasswordGateway encodePasswordGateway = mock(EncodePasswordGateway.class);
EncodePassword encodePassword = new EncodePasswordImpl(encodePasswordGateway);
when(encodePasswordGateway.encode(password)).thenReturn(encodedPassword);
String result = encodePassword.encode(password);
assertThat(result).isEqualTo(encodedPassword);
}
}
CreateUserImplTests.java
package com.br.walletwise.application.usecaseimpl;
import com.br.walletwise.application.gateway.CreateUserGateway;
import com.br.walletwise.application.mocks.MocksFactory;
import com.br.walletwise.application.usecasesimpl.CreateUserImpl;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.core.exception.ConflictException;
import com.br.walletwise.usecase.CreateUser;
import com.br.walletwise.usecase.EncodePassword;
import com.br.walletwise.usecase.FindByEmail;
import com.br.walletwise.usecase.FindByUsername;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.Mockito.*;
class CreateUserImplTest {
private CreateUser createUser;
private FindByUsername findByUsername;
private FindByEmail findByEmail;
private EncodePassword encodePassword;
private CreateUserGateway createUserGateway;
@BeforeEach
void setUp() {
this.findByUsername = mock(FindByUsername.class);
this.findByEmail = mock(FindByEmail.class);
this.encodePassword = mock(EncodePassword.class);
this.createUserGateway = mock(CreateUserGateway.class);
this.createUser = new CreateUserImpl(findByUsername, findByEmail, encodePassword, createUserGateway);
}
@Test
@DisplayName("Should throw BusinessException if username already exists")
void shouldThrowBusinessExceptionIfUserNameAlreadyExists() {
User user = MocksFactory.userWithNoIdFactory();
when(this.findByUsername.find(user.getUsername())).thenReturn(Optional.of(user));
Throwable exception = catchThrowable(() -> this.createUser.create(user));
assertThat(exception).isInstanceOf(ConflictException.class);
assertThat(exception.getMessage()).isEqualTo("Username already taken.");
verify(this.findByUsername, times(1)).find(user.getUsername());
}
@Test
@DisplayName("Should throw BusinessException if email is already in use")
void shouldThrowBusinessExceptionIdEmailIsAlreadyInUse() {
User user = MocksFactory.userWithNoIdFactory();
when(this.findByUsername.find(user.getUsername())).thenReturn(Optional.empty());
when(this.findByEmail.find(user.getEmail())).thenReturn(Optional.of(user));
Throwable exception = catchThrowable(() -> this.createUser.create(user));
assertThat(exception).isInstanceOf(ConflictException.class);
assertThat(exception.getMessage()).isEqualTo("E-mail already in use.");
verify(this.findByUsername, times(1)).find(user.getUsername());
verify(this.findByEmail, times(1)).find(user.getEmail());
}
@Test
@DisplayName("Should create user on success")
void shouldCreateUserOnSuccess() {
User user = MocksFactory.userWithNoIdFactory();
String encodedPassword = UUID.randomUUID().toString();
User userWithEncodedPassword = user;
userWithEncodedPassword.setPassword(encodedPassword);
when(this.findByUsername.find(user.getUsername())).thenReturn(Optional.empty());
when(this.findByEmail.find(user.getEmail())).thenReturn(Optional.empty());
when(this.encodePassword.encode(user.getPassword())).thenReturn(encodedPassword);
this.createUser.create(user);
verify(this.findByUsername, times(1)).find(user.getUsername());
verify(this.findByEmail, times(1)).find(user.getEmail());
verify(this.encodePassword, times(1)).encode(user.getPassword());
verify(this.createUserGateway, times(1)).create(userWithEncodedPassword);
}
}
A estrutura dos diretórios para a camada application ficará assim:
application/
└── src/
└── main/
└── java/
└── com/
└── br/
└── walletwise/
└── application/
├── usecaseImpl/
│ ├── CreateUserImpl.java
│ ├── FindByEmailImpl.java
│ ├── FindByUsernameImpl.java
│ └── EncodePasswordImpl.java
└── gateway/
├── CreateUserGateway.java
├── FindByEmailGateway.java
├── FindByUsernameGateway.java
└── EncodePasswordGateway.java
└── test/
└── java/
└── com/
└── br/
└── walletwise/
└── application/
└── usecaseImpl/
├── CreateUserImplTests.java
├── FindByEmailImplTests.java
├── FindByUsernameImplTests.java
└── EncodePasswordImplTests.java
└── pom.xml
Desenvolvimento da Camada infra
Por fim, vamos desenvolver a camada de infraestrutura, que é responsável por fornecer suporte aos componentes externos, como frameworks, bibliotecas e ferramentas de persistência. Ela integra o sistema com o mundo externo, lidando com a comunicação com bancos de dados, APIs externas e fornecendo implementações para os controllers que expõem a funcionalidade da aplicação.
Além disso, a camada de infraestrutura é responsável pela configuração dos beans da camada de aplicação, garantindo que todos os componentes do sistema sejam corretamente configurados e integrados.
Configuraçoes da aplicação:
ApplicationConfig.java
package com.br.walletwise.infra.config;
import com.br.walletwise.application.gateway.EncodePasswordGateway;
import com.br.walletwise.application.usecasesimpl.EncodePasswordImpl;
import com.br.walletwise.usecase.EncodePassword;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class ApplicationConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public EncodePassword encodePassword(EncodePasswordGateway encodePasswordGateway) {
return new EncodePasswordImpl(encodePasswordGateway);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
AuthenticationProvider authenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return authenticationProvider;
}
}
SecurityConfig.java
package com.br.walletwise.infra.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.POST, "/users/**").permitAll()
.requestMatchers("/",
"/swagger-ui/**",
"/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
SwaggerConfig.java
package com.br.walletwise.infra.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Collections;
@Configuration
public class SwaggerConfig {
@Value("${spring.application.name}")
private String appName;
@Value("${app.version}")
private String appVersion;
@Value("${app.environment}")
private String appEnvironment;
@Value("${app.server.url}")
private String appServerUrl;
@Bean
public OpenAPI openAPI() {
String description = "The Personal Budgeting App" + this.appName + "is your comprehensive financial companion,\n" +
" designed to empower users in managing their finances effectively.\n" +
" With intuitive features, it allows you to track expenses,\n" +
" set realistic budgets, and achieve financial goals. Gain insights through visual reports,\n" +
" receive personalized spending suggestions, and take control of your financial well-being.\n" +
" Elevate your financial literacy with educational resources integrated within the app.\n" +
" Start your journey to financial wellness with the Personal Budgeting App today.";
return new OpenAPI()
.info(new Info()
.title(this.appName.toUpperCase() + " - " + this.appEnvironment.toLowerCase())
.version(this.appVersion)
.description(description))
.servers(Collections.singletonList(
new Server().url(appServerUrl).description("Default url")
));
}
}
UserConfig.java
package com.br.walletwise.infra.config;
import com.br.walletwise.application.gateway.*;
import com.br.walletwise.application.usecasesimpl.*;
import com.br.walletwise.usecase.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class UserConfig {
@Bean
public CreateUser createUser(FindByUsername findByUsername,
FindByEmail findByEmail,
EncodePassword encodePassword,
CreateUserGateway createUserGateway) {
return new CreateUserImpl(findByUsername, findByEmail, encodePassword, createUserGateway);
}
@Bean
public FindByUsername findByUsername(FindByUsernameGateway findByUsernameGateway) {
return new FindByUsernameImpl(findByUsernameGateway);
}
@Bean
public FindByEmail findByEmail(FindByEmailGateway findByEmailGateway) {
return new FindByEmailImpl(findByEmailGateway);
}
}
Entidades e repositorios JPA:
BaseEntity.java
package com.br.walletwise.infra.persistence.entity;
import java.io.Serializable;
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
}
UserJpaEntity.java
package com.br.walletwise.infra.persistence.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "t_users", schema = "security")
public class UserJpaEntity {
@Id
@Column(length = 32)
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String firstname;
@Column(nullable = false)
private String lastname;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(name = "is_active")
private boolean active;
@CreationTimestamp
@Column(nullable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(nullable = false)
private LocalDateTime updatedAt;
}
UserJpaRepository.java
package com.br.walletwise.infra.persistence.repository;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, UUID> {
@Query("SELECT user FROM UserJpaEntity user WHERE user.username = :username AND user.active = true")
Optional<UserJpaEntity> findByUsername(@Param("username") String username);
@Query("SELECT user FROM UserJpaEntity user WHERE user.email = :email AND user.active = true")
Optional<UserJpaEntity> findByEmail(@Param("email") String email);
}
Mapper:
UserMapper .java
package com.br.walletwise.infra.mappers;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.entrypoint.dto.CreateUserRequest;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import org.springframework.stereotype.Component;
@Component
public class UserMapper {
public User map(CreateUserRequest request) {
return new User(
request.firstname(),
request.lastname(),
request.username(),
request.email(),
request.password());
}
public UserJpaEntity map(User user) {
return UserJpaEntity
.builder()
.id(user.getId())
.firstname(user.getFirstname())
.lastname(user.getLastname())
.username(user.getUsername())
.email(user.getEmail())
.password(user.getPassword())
.active(user.isActive())
.build();
}
public User map(UserJpaEntity entity) {
return new User(
entity.getId(),
entity.getFirstname(),
entity.getLastname(),
entity.getUsername(),
entity.getEmail(),
entity.getPassword(),
entity.isActive()
);
}
}
Services:
FindByUsernameGatewayImpl.java
package com.br.walletwise.infra.service;
import com.br.walletwise.application.gateway.FindByUsernameGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class FindByUsernameGatewayImpl implements FindByUsernameGateway {
private final UserJpaRepository userJpaRepository;
private final UserMapper mapper;
@Override
public Optional<User> find(String username) {
return this.userJpaRepository.findByUsername(username).map(this.mapper::map);
}
}
FindByEmailGatewayImpl.java
package com.br.walletwise.infra.service;
import com.br.walletwise.application.gateway.FindByEmailGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class FindByEmailGatewayImpl implements FindByEmailGateway {
private final UserJpaRepository userJpaRepository;
private final UserMapper mapper;
@Override
public Optional<User> find(String email) {
return this.userJpaRepository.findByEmail(email).map(this.mapper::map);
}
}
EncodePasswordGatewayImpl.java
package com.br.walletwise.infra.service;
import com.br.walletwise.application.gateway.EncodePasswordGateway;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class EncodePasswordGatewayImpl implements EncodePasswordGateway {
private final PasswordEncoder passwordEncoder;
@Override
public String encode(String password) {
return this.passwordEncoder.encode(password);
}
}
CreateUserGatewayImpl.java
package com.br.walletwise.infra.service;
import com.br.walletwise.application.gateway.CreateUserGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@Transactional
@RequiredArgsConstructor
public class CreateUserGatewayImpl implements CreateUserGateway {
private final UserJpaRepository userRepository;
private final UserMapper mapper;
@Override
public User create(User user) {
UserJpaEntity entity = this.mapper.map(user);
entity = this.userRepository.save(entity);
return this.mapper.map(entity);
}
}
Agoravos definir o controller:
package com.br.walletwise.infra.entrypoint.controller.user;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.core.exception.ConflictException;
import com.br.walletwise.core.exception.DomainException;
import com.br.walletwise.infra.entrypoint.dto.CreateUserRequest;
import com.br.walletwise.infra.entrypoint.dto.Response;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.usecase.CreateUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/users")
@Tag(name = "User")
@RequiredArgsConstructor
public class CreateUserController {
private final CreateUser usecase;
private final UserMapper mapper;
@PostMapping
@Operation(summary = "Create User")
@ResponseStatus(HttpStatus.CREATED)
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Returns successful message"),
@ApiResponse(responseCode = "400", description = "Bad request happened"),
@ApiResponse(responseCode = "409", description = "Conflict with business rules"),
@ApiResponse(responseCode = "500", description = "An unexpected error occurred."),
})
public ResponseEntity<Response> perform(@RequestBody CreateUserRequest request) {
try {
User user = mapper.map(request);
this.usecase.create(user);
Response response = Response.builder().body("User Created.").build();
return new ResponseEntity<>(response, HttpStatus.CREATED);
} catch (DomainException ex) {
Response response = Response.builder().body(ex.getMessage()).build();
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
} catch (ConflictException ex) {
Response response = Response.builder().body(ex.getMessage()).build();
return new ResponseEntity<>(response, HttpStatus.CONFLICT);
} catch (Exception ex) {
Response response = Response
.builder().body("An unexpected error occurred. Please try again later.").build();
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
Agora vamos criar nossos teste:
Primeiro irei criar classe MocksFactory
para facilitar a escrita dos nosos testes:
package com.br.walletwise.infra.mocks;
import com.br.walletwise.core.domain.entity.Session;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.entrypoint.dto.CreateUserRequest;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import com.github.javafaker.Faker;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
public class MocksFactory {
public static Faker faker = new Faker();
public static User userWithNoIdFactory() {
return new User(
null,
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
"Password!1234H",
true);
}
public static User userFactory() {
return new User(
UUID.randomUUID(),
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
"Password!1234H",
true);
}
public static User userFactory(UserJpaEntity entity) {
return new User(
entity.getId(),
entity.getFirstname(),
entity.getLastname(),
entity.getUsername(),
entity.getEmail(),
entity.getPassword(),
entity.isActive()
);
}
public static User userFactory(CreateUserRequest request) {
return new User(
request.firstname(),
request.lastname(),
request.username(),
request.email(),
request.password()
);
}
public static UserJpaEntity userJpaEntityFactory() {
return new UserJpaEntity(
UUID.randomUUID(),
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
"Password!1234H",
true,
LocalDateTime.now(),
LocalDateTime.now(),
List.of()
);
}
public static UserJpaEntity userJpaEntityFactory(User user){
return UserJpaEntity
.builder()
.id(user.getId())
.firstname(user.getFirstname())
.lastname(user.getLastname())
.username(user.getUsername())
.email(user.getEmail())
.password(user.getPassword())
.active(user.isActive())
.build();
}
public static UserJpaEntity userJpaEntityFactory(UserJpaEntity user){
return UserJpaEntity
.builder()
.id(UUID.randomUUID())
.firstname(user.getFirstname())
.lastname(user.getLastname())
.username(user.getUsername())
.email(user.getEmail())
.password(user.getPassword())
.active(true)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
}
public static CreateUserRequest createUserRequestFactory(){
return new CreateUserRequest(
faker.name().firstName(),
faker.name().lastName(),
faker.name().username(),
faker.internet().emailAddress(),
"Password!1234H");
}
}
CreateCreateUserControllerTests.java
package com.br.walletwise.infra.entrypoint.user;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.core.exception.ConflictException;
import com.br.walletwise.core.exception.DomainException;
import com.br.walletwise.infra.entrypoint.dto.CreateUserRequest;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.mocks.MocksFactory;
import com.br.walletwise.usecase.CreateUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.context.WebApplicationContext;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
public class CreateCreateUserControllerTests {
private final String URL = "/users";
@Autowired
private WebApplicationContext context;
@MockBean
private MockMvc mvc;
@MockBean
private CreateUser createUser;
@MockBean
private UserMapper mapper;
@BeforeEach
public void setup() {
mvc = MockMvcBuilders
.webAppContextSetup(context)
.build();
}
@Test
@DisplayName("Should return 500 if unexpected exception is thrown")
void shouldTReturn500IfUnexpectedExceptionIsThrown() throws Exception {
CreateUserRequest requestParams = MocksFactory.createUserRequestFactory();
User user = MocksFactory.userFactory(requestParams);
String json = new ObjectMapper().writeValueAsString(requestParams);
when(this.mapper.map(requestParams)).thenReturn(user);
doThrow(HttpServerErrorException.InternalServerError.class).when(this.createUser).create(user);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.post(this.URL)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(json);
mvc
.perform(request)
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("body",
Matchers.is("An unexpected error occurred. Please try again later.")));
verify(createUser, times(1)).create(user);
}
@Test
@DisplayName("Should return 409 if conflict exception is thrown")
void shouldTReturn409IfConflictExceptionIsThrown() throws Exception {
CreateUserRequest requestParams = MocksFactory.createUserRequestFactory();
User user = MocksFactory.userFactory(requestParams);
String json = new ObjectMapper().writeValueAsString(requestParams);
when(this.mapper.map(requestParams)).thenReturn(user);
doThrow(new ConflictException("any exception")).when(this.createUser).create(user);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.post(this.URL)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(json);
mvc
.perform(request)
.andExpect(status().isConflict())
.andExpect(jsonPath("body", Matchers.is("any exception")));
verify(createUser, times(1)).create(user);
}
@Test
@DisplayName("Should return 400 if domain exception is thrown")
void shouldTReturn400IfDomainExceptionIsThrown() throws Exception {
CreateUserRequest requestParams = MocksFactory.createUserRequestFactory();
User user = MocksFactory.userFactory(requestParams);
String json = new ObjectMapper().writeValueAsString(requestParams);
when(this.mapper.map(requestParams)).thenReturn(user);
doThrow(new DomainException("any exception")).when(this.createUser).create(user);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.post(this.URL)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(json);
mvc
.perform(request)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("body", Matchers.is("any exception")));
verify(createUser, times(1)).create(user);
}
@Test
@DisplayName("Should return 200 on create user success")
void shouldTReturn200OnCreatUserSuccess() throws Exception {
CreateUserRequest requestParams = MocksFactory.createUserRequestFactory();
User user = MocksFactory.userFactory(requestParams);
String json = new ObjectMapper().writeValueAsString(requestParams);
when(this.mapper.map(requestParams)).thenReturn(user);
doNothing().when(this.createUser).create(user);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.post(this.URL)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(json);
mvc
.perform(request)
.andExpect(status().isCreated())
.andExpect(jsonPath("body", Matchers.is("User Created.")));
verify(createUser, times(1)).create(user);
}
}
UserMapperTests.java
package com.br.walletwise.infra.mappers;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.entrypoint.dto.CreateUserRequest;
import com.br.walletwise.infra.mocks.MocksFactory;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class UserMapperTests {
@Autowired
private UserMapper mapper;
@Test
@DisplayName("Should return user on map from creatUserRequest")
void shouldReturnUserOnMapFromCreatUserRequest() {
CreateUserRequest request = MocksFactory.createUserRequestFactory();
User user = this.mapper.map(request);
assertThat(user).isNotNull();
assertThat(user.getFirstname()).isEqualTo(request.firstname());
assertThat(user.getLastname()).isEqualTo(request.lastname());
assertThat(user.getUsername()).isEqualTo(request.username());
assertThat(user.getEmail()).isEqualTo(request.email());
assertThat(user.getPassword()).isEqualTo(request.password());
}
@Test
@DisplayName("Should return UserJpaEntity on map from user")
void shouldReturnUserJpaEntityOnMapFromUser() {
User user = MocksFactory.userFactory();
UserJpaEntity userJpaEntity = this.mapper.map(user);
assertThat(userJpaEntity.getId()).isEqualTo(user.getId());
assertThat(userJpaEntity.getFirstname()).isEqualTo(user.getFirstname());
assertThat(userJpaEntity.getLastname()).isEqualTo(user.getLastname());
assertThat(userJpaEntity.getUsername()).isEqualTo(user.getUsername());
assertThat(userJpaEntity.getEmail()).isEqualTo(user.getEmail());
assertThat(userJpaEntity.getPassword()).isEqualTo(user.getPassword());
assertThat(userJpaEntity.isActive()).isEqualTo(user.isActive());
}
@Test
@DisplayName("Should return user on map from UserJpaEntity")
void shouldReturnUserOnMapFromUserJpaEntity() {
UserJpaEntity userJpaEntity = MocksFactory.userJpaEntityFactory();
User user = this.mapper.map(userJpaEntity);
assertThat(userJpaEntity.getId()).isEqualTo(user.getId());
assertThat(userJpaEntity.getFirstname()).isEqualTo(user.getFirstname());
assertThat(userJpaEntity.getLastname()).isEqualTo(user.getLastname());
assertThat(userJpaEntity.getUsername()).isEqualTo(user.getUsername());
assertThat(userJpaEntity.getEmail()).isEqualTo(user.getEmail());
assertThat(userJpaEntity.getPassword()).isEqualTo(user.getPassword());
assertThat(userJpaEntity.isActive()).isEqualTo(user.isActive());
}
}
FindByUsernameGatewayImplTests.java
package com.br.walletwise.infra.service;
import com.br.walletwise.application.gateway.FindByUsernameGateway;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.mocks.MocksFactory;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@SpringBootTest
class FindByUsernameGatewayImplTests {
@Autowired
FindByUsernameGateway findByUsernameGateway;
@MockBean
private UserJpaRepository userJpaRepository;
@MockBean
private UserMapper mapper;
@Test
@DisplayName("Should return optional of user")
void shouldReturnOptionalOfUser() {
User user = MocksFactory.userFactory();
UserJpaEntity entity = MocksFactory.userJpaEntityFactory(user);
when(this.userJpaRepository.findByUsername(user.getUsername())).thenReturn(Optional.of(entity));
when(this.mapper.map(entity)).thenReturn(user);
Optional<User> result = this.findByUsernameGateway.find(user.getUsername());
assertThat(result).isPresent();
assertThat(result.get().getId()).isEqualTo(user.getId());
assertThat(result.get().getEmail()).isEqualTo(user.getEmail());
verify(this.userJpaRepository, times(1)).findByUsername(user.getUsername());
verify(this.mapper, times(1)).map(entity);
}
}
FindByEmailGatewayImplTests.java
package com.br.walletwise.infra.service;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.mocks.MocksFactory;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@SpringBootTest
class FindByEmailGatewayImplTests {
@Autowired
FindByEmailGatewayImpl findByEmailJpaGateway;
@MockBean
private UserJpaRepository userJpaRepository;
@MockBean
private UserMapper mapper;
@Test
@DisplayName("Should return optional of user")
void shouldReturnOptionalOfUser() {
User user = MocksFactory.userFactory();
UserJpaEntity entity = MocksFactory.userJpaEntityFactory(user);
when(this.userJpaRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(entity));
when(this.mapper.map(entity)).thenReturn(user);
Optional<User> result = this.findByEmailJpaGateway.find(user.getEmail());
assertThat(result).isPresent();
assertThat(result.get().getId()).isEqualTo(user.getId());
assertThat(result.get().getEmail()).isEqualTo(user.getEmail());
verify(this.userJpaRepository, times(1)).findByEmail(user.getEmail());
verify(this.mapper, times(1)).map(entity);
}
}
EncodePasswordGatewayImplTests.java
package com.br.walletwise.infra.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.crypto.password.PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@SpringBootTest
class EncodePasswordGatewayImplTests {
@Autowired
private EncodePasswordGatewayImpl encodePasswordJpaGateway;
@MockBean
private PasswordEncoder passwordEncoder;
@Test
@DisplayName("Should encode password")
public void shouldEncodePassword() {
String password = "password";
String encodedPassword = "encodedPassword";
when(this.passwordEncoder.encode(password)).thenReturn(encodedPassword);
String result = encodePasswordJpaGateway.encode(password);
assertThat(result).isEqualTo(encodedPassword);
}
}
CreateUserGatewayImplTests.java
package com.br.walletwise.infra.service;
import com.br.walletwise.core.domain.entity.User;
import com.br.walletwise.infra.mappers.UserMapper;
import com.br.walletwise.infra.mocks.MocksFactory;
import com.br.walletwise.infra.persistence.entity.UserJpaEntity;
import com.br.walletwise.infra.persistence.repository.UserJpaRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@SpringBootTest
class CreateUserGatewayImplTests {
@Autowired
private CreateUserGatewayImpl createUserJpaGateway;
@MockBean
private UserJpaRepository userJpaRepository;
@MockBean
private UserMapper mapper;
@Test
@DisplayName("Should save user")
void shouldSaveUser() {
User user = MocksFactory.userWithNoIdFactory();
UserJpaEntity userJpaEntity = MocksFactory.userJpaEntityFactory(user);
UserJpaEntity savedUserJpaEntity = MocksFactory.userJpaEntityFactory(userJpaEntity);
User savedUser = MocksFactory.userFactory(savedUserJpaEntity);
when(this.mapper.map(user)).thenReturn(userJpaEntity);
when(this.userJpaRepository.save(userJpaEntity)).thenReturn(savedUserJpaEntity);
when(this.mapper.map(savedUserJpaEntity)).thenReturn(savedUser);
User result = this.createUserJpaGateway.create(user);
assertThat(result.getId()).isEqualTo(savedUser.getId());
verify(this.mapper,times(1)).map(user);
verify(this.userJpaRepository,times(1)).save(userJpaEntity);
verify(this.mapper,times(1)).map(savedUserJpaEntity);
}
}
Agora vamos adicionar os arquivos application.properties e application-test.properties à nossa configuração. Estes arquivos são essenciais para definir as configurações da aplicação em diferentes ambientes e garantir que a aplicação funcione corretamente tanto em produção quanto em testes.
application.properties
## APPLICATION CONFIGURATION
spring.profiles.active=${SPRING_PROFILES_ACTIVE:dev}
server.port=${PORT:8080}
app.version=${APP_VERSION:2}
app.server.url=${APP_SERVER_URL:http://localhost:8080}
app.environment=${APP_ENVIRONMENT:staging}
spring.application.name=${SPRING_APPLICATION_NAME:walletwise-api}
## DATABASE CONFIGURATION
spring.datasource.hikari.maximum-pool-size=3
spring.datasource.url=jdbc:postgresql://localhost/walletwise
spring.datasource.driverClassName=org.postgresql.Driver
spring.datasource.username=walletwise
spring.datasource.password=walletwise
spring.jpa.show-sql=true
#spring.jpa.hibernate.ddl-auto=update
spring.sql.init.mode=always
spring.flyway.locations=classpath:migrations
application-test.properties
## DATABASE CONFIGURATION
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
#spring.jpa.hibernate.ddl-auto=update
spring.sql.init.mode=always
spring.flyway.locations=classpath:migrations
Vamos adicionar nossa migração de banco de dados para criar a tabela de usuário.
V1__create_user_table.sql
V1__create_user_table.sql
CREATE schema IF NOT EXISTS security;
CREATE TABLE IF NOT EXISTS security.t_users
(
id UUID NOT NULL PRIMARY KEY,
firstname VARCHAR(100) NOT NULL,
lastname VARCHAR(100) NOT NULL,
username VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(64) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Testes e Validação
Vamos seguir o procedimento abaixo para garantir que os testes sejam executados corretamente e validar a funcionalidade da aplicação:
Primeiro, execute o comando mvn clean verify
. Esse comando realiza a limpeza de arquivos antigos e compila o código, além de rodar todos os testes automatizados e gerar um relatório de cobertura de testes. A cobertura é avaliada e o resultado pode ser consultado no módulo de cobertura.
Após a execução dos testes e verificação da cobertura, inicie a aplicação para realizar testes funcionais. Para isso você pode iniciar a aplicação com o comando mvn spring-boot:run
.
Uma vez que a aplicação esteja em execução, abra seu navegador e acesse o Swagger UI para testar a funcionalidade da API. Use o seguinte link para acessar o Swagger UI e interagir com os endpoints da sua aplicação: http://localhost:8080/swagger-ui/index.html#
Conclusão
Neste artigo, exploramos a aplicação da Arquitetura Limpa (Clean Architecture) no contexto de um projeto Spring Boot, descomplicando conceitos e práticas para uma implementação eficaz. Iniciamos com a estruturação das camadas principais: Core, UseCase, Application e Infrastructure, cada uma desempenhando um papel crucial na construção de um sistema modular e escalável.
A camada Core definiu as entidades e validações fundamentais, garantindo a integridade e consistência dos dados. Em seguida, a camada UseCase implementou a lógica dos casos de uso, facilitando a interação entre o domínio e as operações de negócio. A camada Application consolidou a implementação dos casos de uso e a configuração dos gateways, promovendo a integração com ferramentas externas. Por fim, a camada Infrastructure lidou com a comunicação com o mundo externo, incluindo bancos de dados, APIs e configuração de beans, além de garantir a correta injeção de dependências.
Com a adição de testes e configurações apropriadas, como application.properties
e application-test.properties
, e a execução dos testes através do comando mvn clean verify
, garantimos a robustez e a cobertura do sistema. A execução da aplicação e a validação funcional através do Swagger UI permitiram verificar a operabilidade dos endpoints da API.
Esse processo demonstra como a Arquitetura Limpa pode ser aplicada de forma prática, promovendo um design bem organizado, modular e fácil de manter. A adoção dessas práticas não só melhora a qualidade do código, mas também facilita a evolução e a escalabilidade da aplicação, garantindo um sistema mais eficiente e adaptável às mudanças.
Referências
- Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall.
- Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.
- Vernier, T. (2021). Spring Boot: Up and Running: Building Cloud Native Java and Kotlin Applications. O'Reilly Media.
- Knuth, D. E. (1997). The Art of Computer Programming. Addison-Wesley.
- Martin, R. C. (2012). The Clean Architecture. Blog post. Disponível em: Uncle Bob's Blog.
- Baeldung. (2020). A Guide to Clean Architecture in Java. Disponível em: Baeldung.
- Spring Framework. (2021). Spring Boot Reference Documentation. Disponível em: Spring Docs.
- Flyway. (2021). Database Migrations Made Easy. Disponível em: Flyway by Redgate.
- Manguinho, R. (2021). NodeJs Avançado com TDD, Clean Architecture e Typescript. Disponível em: Udemy.
- Santos, D. (2023). Curso de Clean Architecture com Java. Disponível em: YouTube.
Posted on August 13, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.