Pular para conteúdo

Arquitetura Limpa

A Clean Architecture foi proposta por Robert C. Martin (Uncle Bob) como uma síntese de várias ideias anteriores de arquitetura em camadas — Hexagonal, Onion e BCE. Sua regra central é a Regra de Dependência: as dependências de código-fonte devem sempre apontar para dentro, em direção às políticas de nível mais alto1.

Clean Architecture

Fonte: The Clean Code Blog — Robert C. Martin

Camadas

Camada Também chamada de Responsabilidade
Entities Domínio Regras de negócio e estruturas de dados corporativas. Independentes de qualquer aplicação.
Use Cases Aplicação Regras de negócio específicas da aplicação. Orquestra as Entities; define o que a aplicação faz.
Interface Adapters Adapters Converte dados entre os Use Cases e os formatos externos: Controllers, Presenters, Gateways.
Frameworks & Drivers Infraestrutura Frameworks web, bancos de dados, UI, APIs externas. O anel mais externo — todos os detalhes ficam aqui.

A Regra de Dependência

Nada em uma camada interna pode referenciar algo de uma camada externa. Os dados que atravessam as fronteiras devem estar em estruturas simples — objetos simples ou records —, nunca em um tipo de framework. Uma anotação @Entity ou um HttpServletRequest nunca devem aparecer dentro de uma classe de Use Case ou de Entity.

O que vai em cada lugar

Uma heurística prática para decidir a qual camada uma classe pertence:

  • Se ela muda quando o negócio muda → Entity ou Use Case
  • Se ela muda quando a interface muda (REST → gRPC, JSON → XML) → Interface Adapter
  • Se ela muda quando uma biblioteca é atualizada (versão do Spring Boot, dialeto JPA) → Frameworks & Drivers

Na nossa arquitetura

O diagrama abaixo mostra como as camadas da Clean Architecture se mapeiam nas classes e pacotes usados nos microsserviços Spring Boot da disciplina:

flowchart LR
  subgraph Adapters ["Interface Adapters (verde)"]
    direction TB
    Interface:::adapter
    RecordIn:::adapter
    RecordOut:::adapter
  end
  subgraph UseCases ["Use Cases (vermelho)"]
    direction TB
    Service:::usecase
    DTO:::usecase
  end
  subgraph Entities ["Entities (amarelo)"]
    direction TB
    Repository:::entity
    Table:::entity
  end

  Interface --> RecordIn
  Interface --> RecordOut

  Adapters <-->|Parser| UseCases
  Service --> DTO
  UseCases <-->|Mapper| Entities
  Repository --> Table

  classDef adapter fill:#6f6
  classDef usecase fill:#f99
  classDef entity fill:#ff9

Papel de cada componente

RecordIn / RecordOut : DTOs de entrada/saída que ficam na fronteira dos adapters. Eles desacoplam o contrato HTTP do modelo interno, de modo que mudar o nome de um campo JSON ou adicionar uma anotação de validação nunca afeta o Use Case.

Parser : Converte entre os tipos Record* e os DTOs dos Use Cases. É o único lugar que conhece os dois mundos — mantendo os dois lados da fronteira limpos.

Service : Implementa a lógica do caso de uso. Depende apenas das interfaces de repositório declaradas na camada de Entities, nunca de JPA, Spring Data ou qualquer outro detalhe de persistência.

Mapper : Converte entre os DTOs dos Use Cases e os objetos @Entity / @Table na fronteira de persistência. A classe @Entity — que carrega as anotações do ORM — fica confinada à fronteira entre Entities e Infraestrutura e nunca vaza para a lógica do Service.

Repository : Uma interface Java simples, declarada na camada de Entities. A implementação JPA (AccountJpaAdapter) fica na camada mais externa e é injetada em tempo de execução pelo contêiner de injeção de dependência do Spring.

Estrutura de pacotes sugerida

com.example.product/
├── domain/                  ← Entities layer
│   ├── Product.java         # Domain object (no framework annotations)
│   └── ProductRepository.java  # Repository interface (port)
├── application/             ← Use Cases layer
│   ├── ProductService.java  # Business logic
│   └── ProductDTO.java      # Data passed across the use-case boundary
├── adapter/                 ← Interface Adapters layer
│   ├── in/
│   │   ├── ProductController.java
│   │   ├── ProductRecordIn.java
│   │   └── ProductRecordOut.java
│   └── out/
│       ├── ProductJpaAdapter.java   # Implements ProductRepository
│       ├── ProductTable.java        # @Entity class
│       └── ProductMapper.java

Benefícios práticos

Testabilidade sem infraestrutura : As regras de negócio da camada de Use Cases podem ser testadas com JUnit puro — sem contexto Spring, sem banco de dados, sem servidor web. Crie um mock da interface Repository e teste o Service em milissegundos.

Mecanismo de entrega substituível : A camada HTTP (REST, gRPC, GraphQL, CLI) pode ser trocada ou rodar em paralelo sem tocar na classe Service. O adapter é a única coisa que muda.

Persistência substituível : Troque o JPA pelo MongoDB, ou por um mapa em memória nos testes, fornecendo uma nova implementação da interface Repository. As camadas de domínio e de aplicação não são afetadas.

Trabalho paralelo entre equipes : As equipes podem desenvolver o adapter de persistência e o adapter REST de forma independente, desde que concordem sobre as interfaces dos ports. O Service pode ser desenvolvido — e totalmente testado — antes que qualquer adapter exista.


A qual camada esta classe pertence?

Um guia rápido de decisão para posicionar uma nova classe:

flowchart TD
    start([Nova classe]) --> q1{Ela contém\nregras de negócio?}
    q1 -->|Sim| q2{Ela depende de\numa anotação de framework?}
    q2 -->|Sim| fix1["Mova a regra de negócio\npara uma classe interna\nDeixe a anotação\nna camada de adapters"]:::warn
    q2 -->|Não| entity["Entity ou Use Case ✓"]:::ok
    q1 -->|Não| q3{Ela traduz\nentre camadas?}
    q3 -->|Sim| adapter["Interface Adapter ✓"]:::ok
    q3 -->|Não| infra["Frameworks & Drivers ✓"]:::ok
    classDef ok fill:#6c6,color:#fff
    classDef warn fill:#f90,color:#000

Exemplo completo funcionando

Os arquivos abaixo mostram uma fatia completa de um microsserviço Account atravessando as quatro camadas. Cada arquivo está em uma camada diferente — repare que as dependências sempre apontam para dentro.

flowchart RL
    AccountController -->|"Parser converte\nRecordIn → DTO"| AccountService
    AccountService -->|"depende da interface"| AccountRepository
    AccountJpaAdapter -->|"implementa"| AccountRepository
    AccountTable -.-|"usado apenas\npelo adapter"| AccountJpaAdapter
    AccountMapper -.-|"usado apenas\npelo adapter"| AccountJpaAdapter

    style AccountController fill:#6f6
    style AccountService fill:#f99
    style AccountRepository fill:#ff9
    style AccountJpaAdapter fill:#fcc
    style AccountTable fill:#fcc
    style AccountMapper fill:#fcc
// Entities layer — pure domain object, zero framework annotations.
// Changes only when business rules about accounts change.
public class Account {

    private final String id;
    private String name;
    private final String email;
    private String passwordHash;

    private Account(String id, String name, String email, String passwordHash) {
        this.id           = id;
        this.name         = name;
        this.email        = email;
        this.passwordHash = passwordHash;
    }

    public static Account of(AccountIn in) {
        if (in.email() == null || !in.email().contains("@"))
            throw new DomainException("Invalid email: " + in.email());
        if (in.password() == null || in.password().length() < 8)
            throw new DomainException("Password must be at least 8 characters");
        return new Account(
            UUID.randomUUID().toString(),
            in.name(),
            in.email(),
            PasswordHash.bcrypt(in.password())
        );
    }

    public boolean matchesPassword(String rawPassword) {
        return PasswordHash.verify(rawPassword, this.passwordHash);
    }

    public String id()           { return id; }
    public String name()         { return name; }
    public String email()        { return email; }
    public String passwordHash() { return passwordHash; }
}
1
2
3
4
5
6
7
8
// Entities layer — repository interface (port) declared by the domain.
// The persistence technology is unknown here.
public interface AccountRepository {
    Optional<Account> findById(String id);
    Optional<Account> findByEmail(String email);
    Account save(Account account);
    void delete(String id);
}
// Use Cases layer — application-specific business logic.
// Depends on domain objects and the repository port interface — nothing from Spring.
public class AccountService {

    private final AccountRepository repository;

    public AccountService(AccountRepository repository) {
        this.repository = repository;
    }

    public AccountOut create(AccountIn in) {
        if (repository.findByEmail(in.email()).isPresent())
            throw new DomainException("Email already registered: " + in.email());
        Account account = Account.of(in);
        repository.save(account);
        return AccountOut.from(account);
    }

    public AccountOut findById(String id) {
        return repository.findById(id)
            .map(AccountOut::from)
            .orElseThrow(() -> new NotFoundException("Account not found: " + id));
    }

    public List<AccountOut> findAll() {
        return repository.findAll().stream()
            .map(AccountOut::from)
            .toList();
    }

    public void delete(String id) {
        if (repository.findById(id).isEmpty())
            throw new NotFoundException("Account not found: " + id);
        repository.delete(id);
    }
}
// Interface Adapters layer — translates HTTP ↔ Use Case.
// The only Spring annotation in this file is @RestController.
// Business logic lives entirely in AccountService.
@RestController
@RequestMapping("/accounts")
public class AccountController implements AccountApi {

    private final AccountService service;

    public AccountController(AccountService service) {
        this.service = service;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public AccountOut create(@RequestBody @Valid AccountIn in) {
        return service.create(in);
    }

    @GetMapping("/{id}")
    public AccountOut findById(@PathVariable String id) {
        return service.findById(id);
    }

    @GetMapping
    public List<AccountOut> findAll() {
        return service.findAll();
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable String id) {
        service.delete(id);
    }
}
// Frameworks & Drivers layer — JPA entity class.
// Carries all ORM annotations so they never appear in domain or use-case classes.
@Entity
@Table(name = "accounts")
public class AccountTable {

    @Id
    @Column(nullable = false, updatable = false)
    private String id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    // JPA requires a no-arg constructor
    protected AccountTable() {}

    public AccountTable(String id, String name, String email, String passwordHash) {
        this.id           = id;
        this.name         = name;
        this.email        = email;
        this.passwordHash = passwordHash;
    }

    public String getId()           { return id; }
    public String getName()         { return name; }
    public String getEmail()        { return email; }
    public String getPasswordHash() { return passwordHash; }
}
// Frameworks & Drivers layer — implements the AccountRepository port using Spring Data JPA.
// This is the only class that knows about JPA, AccountTable, and AccountMapper.
@Repository
public class AccountJpaAdapter implements AccountRepository {

    private final AccountJpaRepository jpa;

    public AccountJpaAdapter(AccountJpaRepository jpa) {
        this.jpa = jpa;
    }

    @Override
    public Optional<Account> findById(String id) {
        return jpa.findById(id).map(AccountMapper::toDomain);
    }

    @Override
    public Optional<Account> findByEmail(String email) {
        return jpa.findByEmail(email).map(AccountMapper::toDomain);
    }

    @Override
    public Account save(Account account) {
        AccountTable saved = jpa.save(AccountMapper.toTable(account));
        return AccountMapper.toDomain(saved);
    }

    @Override
    public void delete(String id) {
        jpa.deleteById(id);
    }
}

// Spring Data JPA interface — lives at the outermost layer
interface AccountJpaRepository extends JpaRepository<AccountTable, String> {
    Optional<AccountTable> findByEmail(String email);
}
// Frameworks & Drivers layer — converts between domain objects and persistence objects.
// The boundary between Use Cases and Frameworks & Drivers layers.
public final class AccountMapper {

    private AccountMapper() {}

    public static Account toDomain(AccountTable table) {
        return Account.reconstruct(
            table.getId(),
            table.getName(),
            table.getEmail(),
            table.getPasswordHash()
        );
    }

    public static AccountTable toTable(Account account) {
        return new AccountTable(
            account.id(),
            account.name(),
            account.email(),
            account.passwordHash()
        );
    }
}

Erros comuns

Vazamento de tipos de framework para dentro

Colocar @Entity, @Column, @JsonProperty ou HttpServletRequest em uma classe de Use Case ou de Entity viola imediatamente a Regra de Dependência. O detalhe do framework passa a ditar a forma da regra de negócio.

Controllers inchados

Controllers que contêm comandos if, chamam vários serviços ou orquestram fluxos absorveram responsabilidades de Use Case. Controllers devem traduzir e delegar — nada mais.

Modelo de domínio anêmico

Entities que são meros sacos de dados (apenas getters/setters), com toda a lógica na camada de Service, são um sinal de que a fronteira entre camadas não está sendo usada. Mova a garantia das invariantes para a Entity.



  1. MARTIN, R. C. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall, 2017.  ↩

  2. Criando um projeto Spring Boot com Arquitetura Limpa, de Giuliana Silva Bezerra ↩