Criada por Robert C. Martin (Uncle Bob) 2012
Quando falamos de arquitetura limpa estamos falando em como criar um modelo de design de aplicação de modo que eu consiga proteger o core do meu negócio, isolando essa camada das camadas mais externas que tendem a variar mais frequentemente com o tempo. Esse design cria uma divisão de componentes.
Esses componentes precisam se comunicar entre eles.
A arquitetura limpa é caracterizada por uma estrutura em camadas, onde as dependências entre as camadas são sempre de baixo para cima (das camadas externas para as internas).
Quando devemos usar? Esse modelo de design nos obriga a implementar essas camadas de proteção ao core da aplicação.
Por esse motivo, usar CA (Clean Architeture) em todo projeto, principalmente projeto pequenos, micro serviços, simples, com equipes pequenas nem sempre é o melhor caminho.
Como desenvolvedores temos sempre a inclinação de querer usar toda a novidade que o mercado aborda, porém, é um erro.
Devemos buscar usar CA em projetos complexos, quando precisamos e devemos proteger a camada de negócio por ser grande e complexa demais, e por ser assim, exige essa proteção, pra facilitar o desenvolvimento em grande escala, facilitando a testabilidade e manutenção da aplicação. Usar esse padrão em todos os projetos irá aumentar a complexidade da sua aplicação desnecessariamente. Dito isso, aqui está o diagrama mais comum da CA.


Esse desenho simples é uma representação de como funciona a comunicação entre os componentes da Arquitetura.
Repare que temos 3 camadas bem definidas, Application, Domain e Infra.
A camada de Domain contêm todas as regras e funcionalidades do domínio da nossa aplicação, vou colocar um exemplo de como devemos construir nossa estrutura.
Vamos para um exemplo prático.
Vamos criar uma classe para representar uma entidade no nosso sistema:
package com.rfsystems.subscription.domain.account;
public class Account extends AggregateRoot<AccountId> {
private int version;
private UserId userId;
private Email email;
private Name name;
private Document document;
private Address billingAddress;
private Account(
final AccountId anAccountId,
final int version,
final UserId anUserId,
final Email anEmail,
final Name aName,
final Document aDocument,
final Address billingAddress
) {
super(anAccountId);
this.setVersion(version);
this.setUserId(anUserId);
this.setEmail(anEmail);
this.setName(aName);
this.setDocument(aDocument);
this.setBillingAddress(billingAddress);
}
public static Account newAccount(
final AccountId anAccountId,
final UserId anUserId,
final Email anEmail,
final Name aName,
final Document aDocument
) {
final var anAccount = new Account(anAccountId, 0, anUserId, anEmail, aName, aDocument, null);
anAccount.registerEvent(new AccountCreated(anAccount));
return anAccount;
}
public static Account with(
final AccountId anAccountId,
final int version,
final UserId anUserId,
final Email anEmail,
final Name aName,
final Document aDocument,
final Address billingAddress
) {
return new Account(anAccountId, version, anUserId, anEmail, aName, aDocument, billingAddress);
}
public void execute(final AccountCommand... cmds) {
if (cmds == null) {
return;
}
for (var cmd : cmds) {
switch (cmd) {
case ChangeProfileCommand c -> apply(c);
case ChangeDocumentCommand c -> apply(c);
case ChangeEmailCommand c -> apply(c);
}
}
}
Então temos aqui um módulo de Domain com uma classe Account que representa uma entidade.
Vamos criar agora os comandos que essa entidade aceita, nesse caso permitiremos mudança de profile, email e documento.
package com.rfsystems.subscription.domain.account;
import com.rfsystems.subscription.domain.person.Address;
import com.rfsystems.subscription.domain.person.Document;
import com.rfsystems.subscription.domain.person.Email;
import com.rfsystems.subscription.domain.person.Name;
public sealed interface AccountCommand {
record ChangeProfileCommand(Name aName, Address aBillingAddress) implements AccountCommand {
}
record ChangeEmailCommand(Email anEmail) implements AccountCommand {
}
record ChangeDocumentCommand(Document aDocument) implements AccountCommand {
}
}
Agora vamos criar um gateway para as camadas de Application e Infrastucture conseguir se comunicar com nosso Domain
package com.rfsystems.subscription.domain.account;
import com.rfsystems.subscription.domain.account.idp.UserId;
import java.util.Optional;
public interface AccountGateway {
AccountId nextId();
Optional<Account> accountOfId(AccountId anId);
Optional<Account> accountOfUserId(UserId userId);
Account save(Account anAccount);
}
Como a camada de domínio não pode ter nenhum framework como o spring por exemplo, temos q ter um identificador para nossa entidade.
package com.rfsystems.subscription.domain.account;
import com.rfsystems.subscription.domain.Identifier;
public record AccountId(String value) implements Identifier<String> {
public AccountId {
this.assertArgumentNotEmpty(value, "'accountId' should not be empty");
}
}
package com.rfsystems.subscription.domain;
public interface Identifier<T> extends ValueObject {
T value();
}
package com.rfsystems.subscription.domain;
public interface ValueObject extends AssertionConcern {
}
package com.rfsystems.subscription.domain;
import com.rfsystems.subscription.domain.exceptions.DomainException;
public interface AssertionConcern {
default <T> T assertArgumentNotNull(T val, String aMessage) {
if (val == null) {
throw DomainException.with(aMessage);
}
return val;
}
default String assertArgumentNotEmpty(String val, String aMessage) {
if (val == null || val.isBlank()) {
throw DomainException.with(aMessage);
}
return val;
}
default String assertArgumentLength(String val, int length, String aMessage) {
if (val == null || val.length() != length) {
throw DomainException.with(aMessage);
}
return val;
}
default void assertConditionTrue(Boolean val, String aMessage) {
if (Boolean.FALSE.equals(val)) {
throw DomainException.with(aMessage);
}
}
default String assertArgumentMaxLength(String val, int length, String aMessage) {
if (val != null && val.length() > length) {
throw DomainException.with(aMessage);
}
return aMessage;
}
}
Agora vamos definir oq é uma entidade:
package com.rfsystems.subscription.domain;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public abstract class Entity<ID extends Identifier> implements AssertionConcern {
protected final ID id;
private final List<DomainEvent> domainEvents;
protected Entity(final ID id) {
this(id, null);
}
protected Entity(final ID id, final List<DomainEvent> domainEvents) {
this.id = this.assertArgumentNotNull(id, "'id' should not be null");
this.domainEvents = new ArrayList<>(domainEvents == null ? Collections.emptyList() : domainEvents);
}
public ID id() {
return id;
}
public List<DomainEvent> domainEvents() {
return Collections.unmodifiableList(domainEvents);
}
public void registerEvent(final DomainEvent event) {
if (event == null) {
return;
}
this.domainEvents.add(event);
}
@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Entity<?> entity = (Entity<?>) o;
return id().equals(entity.id());
}
@Override
public int hashCode() {
return Objects.hash(id());
}
}
Com isso temos o básico pra nossa camada de Domain, poderíamos ainda criar os Handlers pra lidar com lançamento de excessão, Validators, etc.
Vamos criar agora nossa camada de Application. Nessa camadas criaremos a definição básica de UseCase, que pra clean arch é um dos conceitos que definem as funcionalidades que nosso sistema comporta.
package com.rfsystems.subscription.application;
public abstract class UseCase<IN, OUT> {
public abstract OUT execute(IN in);
public <T> T execute(IN in, Presenter<OUT, T> presenter) {
if (presenter == null) {
throw new IllegalArgumentException("UseCase 'presenter' is required");
}
return presenter.apply(execute(in));
}
}
public abstract class UnitUseCase<IN> {
public abstract void execute(IN anIn);
}
A ideia aqui é que todo caso de uso tem uma entrada (IN) e uma saída (OUT) ou casos de uso apenas com entrada.
Com a abstração do UseCase podemos definir a abstração do caso de uso pra criar a Account
package com.rfsystems.subscription.application.account;
import com.rfsystems.subscription.application.UseCase;
import com.rfsystems.subscription.domain.account.AccountId;
public abstract class CreateAccount extends UseCase<CreateAccount.Input, CreateAccount.Output> {
public interface Input {
String accountId();
String userId();
String email();
String firstname();
String lastname();
String documentNumber();
String documentType();
}
public interface Output {
AccountId accountId();
}
}
Aqui demonstramos que nosso caso de uso CreateAccount tem esse input e output. Obrigando quem implementar esse contrato utilizar esse formato.
Agora vamos implementar de fato esse usecase:
import com.rfsystems.subscription.application.account.CreateAccount;
import com.rfsystems.subscription.domain.account.Account;
import com.rfsystems.subscription.domain.account.AccountGateway;
import com.rfsystems.subscription.domain.account.AccountId;
import com.rfsystems.subscription.domain.account.idp.UserId;
import com.rfsystems.subscription.domain.person.Document;
import com.rfsystems.subscription.domain.person.Email;
import com.rfsystems.subscription.domain.person.Name;
import java.util.Objects;
public class DefaultCreateAccount extends CreateAccount {
private final AccountGateway accountGateway;
public DefaultCreateAccount(final AccountGateway accountGateway) {
this.accountGateway = Objects.requireNonNull(accountGateway);
}
@Override
public Output execute(final Input in) {
if (in == null) {
throw new IllegalArgumentException("Input to DefaultCreateAccount cannot be null");
}
final var anUserAccount = this.newAccountWith(in);
this.accountGateway.save(anUserAccount);
return new StdOutput(anUserAccount.id());
}
private Account newAccountWith(final Input in) {
return Account.newAccount(
new AccountId(in.accountId()),
new UserId(in.userId()),
new Email(in.email()),
new Name(in.firstname(), in.lastname()),
Document.create(in.documentNumber(), in.documentType())
);
}
record StdOutput(AccountId accountId) implements Output {
}
}
Note que aqui injetamos o nosso gateway do Domain pra ter acesso ao método save.
Agora é onde as coisas se conectam.
Na camada de Infrastrure precisamos implementar de fato a persistência. Aqui dependemos de framework, de banco de filas, então como o domínio determina, “Ei, salva uma account ai pra nós”, a infra realiza a função que é intermediada pelo caso de uso. Note:
package com.rfsystems.subscription.infrastructure.gateway.repository;
import com.rfsystems.subscription.domain.account.Account;
import com.rfsystems.subscription.domain.account.AccountGateway;
import com.rfsystems.subscription.domain.account.AccountId;
import com.rfsystems.subscription.domain.account.idp.UserId;
import com.rfsystems.subscription.domain.person.Address;
import com.rfsystems.subscription.domain.person.Document;
import com.rfsystems.subscription.domain.person.Email;
import com.rfsystems.subscription.domain.person.Name;
import com.rfsystems.subscription.domain.utils.IdUtils;
import com.rfsystems.subscription.infrastructure.jdbc.DatabaseClient;
import com.rfsystems.subscription.infrastructure.jdbc.RowMap;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@Repository
public class AccountJdbcRepository implements AccountGateway {
private final DatabaseClient database;
private final EventJdbcRepository eventJdbcRepository;
public AccountJdbcRepository(final DatabaseClient databaseClient, final EventJdbcRepository eventJdbcRepository) {
this.database = Objects.requireNonNull(databaseClient);
this.eventJdbcRepository = Objects.requireNonNull(eventJdbcRepository);
}
@Override
public AccountId nextId() {
return new AccountId(IdUtils.uniqueId());
}
@Override
public Optional<Account> accountOfId(final AccountId anId) {
final var sql = """
SELECT
id, version, idp_user_id, email, firstname, lastname, document_number, document_type, address_zip_code, address_number, address_complement, address_country
FROM accounts
WHERE id = :id
""";
return this.database.queryOne(sql, Map.of("id", anId.value()), accountMapper());
}
@Override
public Optional<Account> accountOfUserId(final UserId userId) {
final var sql = """
SELECT
id, version, idp_user_id, email, firstname, lastname, document_number, document_type, address_zip_code, address_number, address_complement, address_country
FROM accounts
WHERE idp_user_id = :userId
""";
return this.database.queryOne(sql, Map.of("userId", userId.value()), accountMapper());
}
@Override
@Transactional(propagation = Propagation.REQUIRED)
public Account save(final Account anAccount) {
if (anAccount.version() == 0) {
create(anAccount);
} else {
update(anAccount);
}
this.eventJdbcRepository.saveAll(anAccount.domainEvents());
return anAccount;
}
private void create(final Account account) {
final var sql = """
INSERT INTO accounts (id, version, idp_user_id, email, firstname, lastname, document_number, document_type, address_zip_code, address_number, address_complement, address_country)
VALUES (:id, (:version + 1), :userId, :email, :firstname, :lastname, :documentNumber, :documentType, :addressZipCode, :addressNumber, :addressComplement, :addressCountry)
""";
executeUpdate(sql, account);
}
private void update(final Account account) {
final var sql = """
UPDATE accounts
SET
version = :version + 1,
idp_user_id = :userId,
email = :email,
firstname = :firstname,
lastname = :lastname,
document_number = :documentNumber,
document_type = :documentType,
address_zip_code = :addressZipCode,
address_number = :addressNumber,
address_complement = :addressComplement,
address_country = :addressCountry
WHERE id = :id and version = :version
""";
if (executeUpdate(sql, account) == 0) {
throw new IllegalArgumentException("Account with id %s and version %s was not found".formatted(account.id().value(), account.version()));
}
}
private int executeUpdate(final String sql, final Account account) {
final var params = new HashMap<String, Object>();
params.put("version", account.version());
params.put("userId", account.userId().value());
params.put("email", account.email().value());
params.put("firstname", account.name().firstname());
params.put("lastname", account.name().lastname());
params.put("documentNumber", account.document().value());
params.put("documentType", account.document().type());
final var address = account.billingAddress();
params.put("addressZipCode", address != null ? address.zipcode() : "");
params.put("addressNumber", address != null ? address.number() : "");
params.put("addressComplement", address != null ? address.complement() : "");
params.put("addressCountry", address != null ? address.country() : "");
params.put("id", account.id().value());
return this.database.update(sql, params);
}
private RowMap<Account> accountMapper() {
return (rs) -> {
final var zipCode = rs.getString("address_zip_code");
return Account.with(
new AccountId(rs.getString("id")),
rs.getInt("version"),
new UserId(rs.getString("idp_user_id")),
new Email(rs.getString("email")),
new Name(rs.getString("firstname"), rs.getString("lastname")),
Document.create(rs.getString("document_number"), rs.getString("document_type")),
zipCode != null && !zipCode.isBlank() ?
new Address(
zipCode,
rs.getString("address_number"),
rs.getString("address_complement"),
rs.getString("address_country")
) :
null
);
};
}
}
Aqui implementamos o Gateway e damos acesso as camadas externas, usuários externos, sistemas a toda nossa lógica.
Podemos inclusive ter uma outra implementação do mesmo Gateway para persistir em memória.
package com.rfsystems.subscription.infrastructure.gateway.repository;
import com.rfsystems.subscription.domain.account.Account;
import com.rfsystems.subscription.domain.account.AccountGateway;
import com.rfsystems.subscription.domain.account.AccountId;
import com.rfsystems.subscription.domain.account.idp.UserId;
import com.rfsystems.subscription.domain.utils.IdUtils;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
//@Component
public class AccountInMemoryRepository implements AccountGateway {
private Map<String, Account> db = new ConcurrentHashMap<>();
private Map<String, Account> userIdIndex = new ConcurrentHashMap<>();
@Override
public AccountId nextId() {
return new AccountId(IdUtils.uniqueId());
}
@Override
public Optional<Account> accountOfId(AccountId anId) {
return Optional.ofNullable(this.db.get(anId.value()));
}
@Override
public Optional<Account> accountOfUserId(UserId userId) {
return Optional.ofNullable(this.userIdIndex.get(userId.value()));
}
@Override
public Account save(Account anAccount) {
this.db.put(anAccount.id().value(), anAccount);
this.userIdIndex.put(anAccount.userId().value(), anAccount);
return anAccount;
}
}
Essa é a ideia principal do design de clean arch.

Lembre-se de quando for criar um projeto evite separar essas camadas apenas por pastas, o ideal é criar módulos e fazer com que o Domain não dependa de módulo nenhum, o Application dependa do Doman e Infra do Application. Assim você protege as camadas e impede que desenvolvedores inexperientes quebrem as camadas com comunicação indevida.
repositório publico temporariamente.
git@github.com:velkanknight/hexagonal.git

Deixe um comentário