Pular para o conteúdo

Arquitetura de software · parte 1 de 6

Arquitetura hexagonal, isolando o domínio do caos externo

Aula completa sobre portas e adaptadores, da analogia do restaurante a testes com fakes, com exemplos em Go, Java, Kotlin e TypeScript e diagramas animados.

intermediárioPublicada em · 27 min de leitura

Tamanho da fonte:
Largura da coluna:

Imagine um restaurante. O cliente faz o pedido pelo garçom, pelo tablet da mesa ou pelo aplicativo de entrega, e a cozinha não precisa saber por qual dos três ele chegou. Ela recebe o pedido no formato combinado, prepara o prato e devolve. Se amanhã o restaurante trocar o tablet por um atendimento por voz, a cozinha continua exatamente igual. A arquitetura hexagonal, também chamada de Ports and Adapters, aplica essa ideia ao software. A regra de negócio fica no centro, sem saber se o pedido chegou por HTTP, por uma fila de mensagens ou por um teste automatizado. Tudo o que é tecnologia (banco de dados, API de pagamento, envio de e-mail, relógio do sistema) fica na borda e conversa com o centro por meio de contratos.

Dois termos aparecem já aqui e voltam o tempo todo. Uma porta é uma interface que o núcleo define para dizer o que precisa ou o que oferece ao mundo externo. Um adaptador é a classe que traduz uma tecnologia concreta para essa interface, como um repositório JDBC ou um cliente HTTP. A versão completa de cada termo aparece nas seções 2 e 3.

Esta aula parte do problema que a arquitetura resolve, passa pelas portas e adaptadores, separa quem inicia a conversa de quem é chamado, mostra como a direção das dependências se traduz em pacotes e em código (com exemplos em Go, Java, Kotlin e TypeScript), explica como testar com fakes, compara a arquitetura com camadas, Clean e Onion, e termina com as armadilhas mais comuns e com os casos em que ela é exagero.

mapa da aula

  1. O problema: o que acontece quando a regra de negócio depende do framework.
  2. Anatomia: núcleo, portas e adaptadores.
  3. Quem chama e quem é chamado: adaptadores dirigentes e dirigidos.
  4. A direção das dependências e a inversão que a torna possível.
  5. Estrutura de pacotes e a regra de que nenhuma seta sai do centro para a borda.
  6. Exemplos de código, com a raiz de composição que liga tudo.
  7. Testes com fakes, comparados com testes de integração.
  8. Erro técnico na borda: quem traduz o quê.
  9. Comparação com camadas, Clean Architecture e Onion Architecture.
  10. Erros comuns e boas práticas.
  11. Quando não usar.
  12. Exercícios e glossário.

1. o problema: a regra de negócio presa ao framework

Imagine um serviço de pedidos cuja regra mais importante seja esta: um pedido só pode ser aprovado se estiver pendente. Essa regra não depende de HTTP, de Spring ou de PostgreSQL. Ela deveria continuar verdadeira se o mesmo código rodasse dentro de um terminal. Na prática, a regra costuma ir parar em lugares errados. A classe Pedido ganha uma anotação de persistência, depois uma anotação para ser injetada pelo framework, e em algum momento um método faz uma chamada HTTP “só para validar uma coisa”. Cada linha dessas parece inofensiva isoladamente. Juntas, elas fazem com que testar a regra exija subir um banco de dados.

O custo aparece em três momentos. O primeiro é o teste. Se a regra só pode ser verificada com o ambiente inteiro de pé, o ciclo de feedback passa de milissegundos para minutos, e o desenvolvedor passa a evitar o teste. O segundo é a mudança. Uma atualização de versão do framework, que deveria ser assunto da borda, acaba mexendo no núcleo, porque o núcleo importa classes do framework. O terceiro é a leitura. Quem chega ao projeto e olha os imports não consegue dizer o que é regra e o que é detalhe técnico, porque tudo está misturado no mesmo pacote.

Alistair Cockburn descreveu a arquitetura em 2005, no artigo que chamou de “Hexagonal Architecture”, com o nome alternativo Ports and Adapters. A ideia central é uma regra de direção: o núcleo não conhece a borda, e a borda conhece o núcleo. O nome vem do desenho, um hexágono, mas o número de lados não carrega significado. O que importa é que o núcleo tem várias faces, e cada face recebe uma porta, seja de entrada ou de saída.

2. anatomia: núcleo, portas e adaptadores

O núcleo é formado por duas coisas. A primeira é o domínio, que guarda as entidades, os valores e as regras que definem o negócio, como o Pedido que só aceita estados válidos. A segunda são os casos de uso, que coordenam o domínio para realizar uma ação do ponto de vista de quem usa o sistema, como “registrar um pedido”. O caso de uso não sabe se o pedido vai para um banco ou para uma planilha. Ele sabe que precisa de algo que salve pedidos e de algo que cobre o pagamento, e expressa essa necessidade em forma de interface.

Essas interfaces são as portas. Uma porta é definida pelo núcleo, e isso importa mais do que parece. Quando o caso de uso precisa salvar um pedido, a interface PedidoRepositorio nasce do que o caso de uso necessita, com os métodos e os tipos do domínio. Ela não nasce do que o banco oferece. Por isso ela tem métodos como Salvar(pedido) e não ExecutarInsertSQL(tabela, colunas). A porta fala a língua do negócio.

Um adaptador é a classe que implementa a porta com uma tecnologia real. O PedidoJdbcRepositorio implementa PedidoRepositorio usando SQL, o ClienteHttpPagamento implementa GatewayPagamento usando uma API externa, e o controller HTTP usa a porta de entrada para chamar o caso de uso. O adaptador traduz nos dois sentidos. Recebe o JSON e vira um comando do domínio, ou recebe um objeto do domínio e vira uma linha no banco.

visao geral
figura 1 · visao geral

A figura mostra o sistema inteiro numa só imagem. Os três controladores à esquerda são adaptadores que chamam o núcleo, e as caixas roxas são as portas que os recebem. No meio está o núcleo, com o caso de uso no centro e o domínio ligado a ele. À direita estão os adaptadores que o núcleo chama, e na ponta estão as tecnologias reais, o PostgreSQL e o provedor de pagamento. Repare que as setas de chamada (as tracejadas que se movem) atravessam a figura da esquerda para a direita, mas as dependências de código, que você vai ver na seção 4, apontam em outra direção. As cores seguem um mapa fixo: laranja para quem chama, roxo para as portas, azul para os adaptadores de saída e verde escuro para o banco.

3. quem chama e quem é chamado

Um adaptador pode estar de dois lados do núcleo, e confundir os dois lados é uma das dúvidas mais comuns. Cockburn chamou os adaptadores do lado que inicia a conversa de primários, e uma parte da literatura usa o nome dirigentes (driving). Já os que o núcleo chama para obter algo são secundários, ou dirigidos (driven). Um adaptador dirigente é quem empurra trabalho para dentro do sistema: controller HTTP, linha de comando, consumidor de fila, job agendado e até o teste. Um adaptador dirigido é quem o sistema procura para obter alguma coisa: banco de dados, gateway de pagamento, relógio, gerador de identificadores e serviço de e-mail.

A separação importa porque ela define quais portas existem. As portas de entrada são as operações que o mundo pode pedir ao núcleo, como RegistrarPedido.executar(...). As portas de saída são as dependências de que o núcleo precisa, como PedidoRepositorio.salvar(...). Quem escreve o teste de um caso de uso também é um adaptador dirigente. Ele chama a porta de entrada, o que significa que o teste não precisa de nada especial dentro do núcleo.

A figura abaixo mostra uma única requisição atravessando as camadas. Ela também é a figura que explica os cinco passos de um pedido.

fluxo requisicao
figura 2 · fluxo requisicao

Leia a figura da esquerda para a direita. O controller recebe o POST /pedidos e chama a porta de entrada, que é o passo 1. A porta encaminha para o caso de uso, que faz o trabalho do negócio e chama a porta de saída do repositório, passos 2 e 3. O adaptador JDBC implementa essa porta e fala com o PostgreSQL, passos 4 e 5. Os pacotes verde claro, que aparecem na animação no sentido contrário, representam a resposta voltando pelo mesmo caminho. Numa imagem parada essa volta quase não se distingue, por isso vale rodar a figura para ver os dois sentidos. O ponto para guardar é que o caso de uso nunca fala com o PostgreSQL diretamente. Ele fala com uma porta, e quem decide que a porta é um banco é a composição, que você vai ver na seção 6.

4. a direção das dependências

Esta é a parte que a arquitetura inteira depende, e por isso merece cuidado. Há duas setas diferentes num sistema. A primeira é a seta de execução: quem chama quem em tempo de execução. Essa seta sempre vai do controller até o banco, porque a requisição precisa chegar lá. A segunda é a seta de dependência: quem precisa conhecer o nome de quem no código-fonte, ou seja, quem importa o quê. Numa arquitetura em camadas tradicional, as duas setas apontam na mesma direção. O caso de uso importa a classe concreta do banco e a chama, e então o núcleo depende da infraestrutura.

A arquitetura hexagonal separa essas duas setas usando a Inversão de Dependência, que está explicada em Inversão de Dependência. A porta é definida no núcleo. O caso de uso depende da porta, e o adaptador também depende da porta, porque precisa implementá-la. Assim, a seta de execução continua indo para o banco, mas a seta de dependência de código aponta para dentro, para o núcleo.

dependencia antes depois
figura 3 · dependencia antes depois

A figura compara os dois mundos. Em cima, o caso de uso importa o JdbcTemplate diretamente, e um detalhe do Spring entra no núcleo. Embaixo, o caso de uso conhece só a interface PedidoRepositorio, que vive no núcleo, e o repositório JDBC aponta para ela com uma seta tracejada, que significa implementação. Repare que na parte de baixo a chamada ainda vai do caso de uso para o repositório, por isso existe uma seta de execução (chama) e uma de implementação (implementa), e ambas convergem para a porta. Se você trocar o banco amanhã, a seta de baixo continua sendo a mesma, e só o retângulo azul muda.

Em código, a inversão tem uma forma pequena e fácil de reconhecer. A interface fica no pacote do caso de uso, e o adaptador importa essa interface, nunca o contrário:

// application/registrar_pedido.go: o núcleo define a porta
type PedidoRepositorio interface {
    Salvar(p Pedido) error
}

Em Go, essa forma aparece naturalmente, porque as interfaces são implícitas. Um tipo satisfaz uma interface apenas por ter os métodos certos, então o adaptador RepositorioSQL não precisa escrever implements em lugar nenhum. Em Java e Kotlin a declaração é explícita, e em TypeScript é estrutural, como em Go. A seção 6 mostra as quatro linguagens.

5. estrutura de pacotes

A regra de dependência só se mantém se a estrutura de pastas deixar a direção visível. Um projeto Go pode organizar as peças assim:

cmd/api/main.go                     raiz de composição
internal/domain/                    entidades e regras puras
internal/application/               casos de uso e portas de saída
internal/adapter/in/http/           controller, adaptador dirigente
internal/adapter/out/postgres/      repositório, adaptador dirigido

O nome das pastas não é um detalhe. Quando alguém abre o projeto, a árvore já conta a história: o que está em domain não depende de nada fora dele, o que está em application depende só do domínio, e o que está em adapter depende do núcleo. A porta de saída mora em application porque é o caso de uso quem precisa dela. Há quem prefira colocar as portas em uma pasta port separada. Isso funciona, desde que a regra continue valendo: a porta não pode importar um adaptador.

Essa direção pode e deve ser verificada por máquina. A figura abaixo mostra o que o build precisa barrar: dependências que vão do centro para a borda e dependências que atravessam a borda de um lado para o outro.

regra de pacotes
figura 4 · regra de pacotes

As setas verdes representam dependências permitidas. O caso de uso usa o domínio, o controller usa o caso de uso, e o repositório implementa a porta. As setas rosas com o símbolo de bloqueio são as proibidas, e elas estão marcadas em duas situações comuns. A primeira é o domínio importando o framework, que deixaria o núcleo dependente do Spring. A segunda é um adaptador chamando outro adaptador, o que cria um acoplamento escondido entre a borda e a borda. Numa verificação automática, essas setas viram erros de build. A aula de Kotlin sobre Arquitetura hexagonal com ArchUnit mostra como o ArchUnit transforma essas regras em testes que falham quando alguém as quebra.

A figura seguinte mostra a mesma ideia com um formato diferente, como anéis concêntricos. Ela é a versão visual do que o nome cebola sugere: o domínio fica no miolo, a aplicação envolve o domínio, e os adaptadores ficam na casca. Cada anel só enxerga os anéis mais internos.

cebola aneis
figura 5 · cebola aneis

Leia a figura de dentro para fora. O domínio, que contém Pedido e Centavos, não tem seta saindo. A aplicação tem o caso de uso, que usa o domínio, e as portas. Os adaptadores, na casca, chamam a aplicação e implementam as portas. A cor do caso de uso e do domínio é a mesma (marrom) porque os dois pertencem ao núcleo, e a cor das portas é roxa porque elas são a fronteira. Se alguma seta saísse do domínio para um adaptador, a figura deixaria de ser uma cebola e viraria um emaranhado, que é exatamente o que a regra quer impedir.

6. exemplos de código

Os exemplos abaixo foram executados nesta revisão, nas versões indicadas no final da aula. O exemplo em Go é o mais completo, porque mostra o caso de uso, a porta, um adaptador de saída que traduz erros e um fake para teste. Ele é um único arquivo para que você possa rodar com go run.

package main

import (
	"errors"
	"fmt"
)

// Núcleo: nenhum import de banco, HTTP ou framework.
type Pedido struct {
	ID    string
	Total int64
}

var ErrPersistencia = errors.New("falha de persistência")

// Porta de saída: definida pelo núcleo, implementada na borda.
type PedidoRepositorio interface {
	Salvar(p Pedido) error
}

type RegistrarPedido struct {
	repo PedidoRepositorio
}

func NovoRegistrarPedido(repo PedidoRepositorio) *RegistrarPedido {
	return &RegistrarPedido{repo: repo}
}

func (uc *RegistrarPedido) Executar(p Pedido) error {
	if p.Total < 0 {
		return errors.New("total negativo")
	}
	if err := uc.repo.Salvar(p); err != nil {
		return fmt.Errorf("registrar pedido: %w", err)
	}
	return nil
}

// Adaptador de saída: traduz erro técnico para erro do núcleo.
type RepositorioSQL struct{ fonte func(Pedido) error }

func (r RepositorioSQL) Salvar(p Pedido) error {
	if err := r.fonte(p); err != nil {
		return fmt.Errorf("%w: %v", ErrPersistencia, err)
	}
	return nil
}

// Fake para teste unitário.
type RepositorioEmMemoria struct{ itens []Pedido }

func (r *RepositorioEmMemoria) Salvar(p Pedido) error {
	r.itens = append(r.itens, p)
	return nil
}

func main() {
	fake := &RepositorioEmMemoria{}
	uc := NovoRegistrarPedido(fake)
	fmt.Println("feliz:", uc.Executar(Pedido{ID: "a", Total: 100}), len(fake.itens))
	fmt.Println("negativo:", uc.Executar(Pedido{ID: "b", Total: -1}))

	falho := NovoRegistrarPedido(RepositorioSQL{fonte: func(Pedido) error { return errors.New("conexão recusada") }})
	err := falho.Executar(Pedido{ID: "c", Total: 5})
	fmt.Println("erro:", err, "| é ErrPersistencia:", errors.Is(err, ErrPersistencia))
}

A saída real, com Go 1.27.2, foi esta:

feliz: <nil> 1
negativo: total negativo
erro: registrar pedido: falha de persistência: conexão recusada | é ErrPersistencia: true

Repare em três detalhes. O caso de uso não importa nada além de errors e fmt, e isso é exatamente a regra da seção 5. O RepositorioSQL é a peça de borda e só ela sabe o que é uma falha de conexão. E a última linha mostra que o erro chega ao caso de uso embrulhado em ErrPersistencia, de modo que quem chama pode verificar a categoria com errors.Is sem conhecer o driver.

Em Java, a mesma ideia usa uma interface aninhada e um record para o pedido. O exemplo abaixo também foi compilado com JDK 21.0.8 e produziu a saída salvos: 1:

import java.util.ArrayList;
import java.util.List;

public class Main {
    record Pedido(String id, long totalCentavos) {}

    // Porta de saída, definida pelo núcleo
    interface PedidoRepositorio {
        void salvar(Pedido pedido);
    }

    // Caso de uso: conhece só a porta
    static final class RegistrarPedido {
        private final PedidoRepositorio repositorio;

        RegistrarPedido(PedidoRepositorio repositorio) {
            this.repositorio = repositorio;
        }

        void executar(Pedido pedido) {
            if (pedido.totalCentavos() < 0) {
                throw new IllegalArgumentException("total negativo");
            }
            repositorio.salvar(pedido);
        }
    }

    // Fake para teste unitário
    static final class RepositorioEmMemoria implements PedidoRepositorio {
        final List<Pedido> itens = new ArrayList<>();

        @Override
        public void salvar(Pedido pedido) {
            itens.add(pedido);
        }
    }

    public static void main(String[] args) {
        var fake = new RepositorioEmMemoria();
        new RegistrarPedido(fake).executar(new Pedido("a", 100));
        System.out.println("salvos: " + fake.itens.size());
    }
}

Em TypeScript, a porta vira uma interface e a implementação usa implements. Como o Node 24 remove os tipos sem transformar o código, a classe declara o campo explicitamente em vez de usar a forma abreviada do construtor. O trecho rodou com a saída salvos: 1:

type Pedido = { id: string; totalCentavos: number };

// Porta de saída: o núcleo define o contrato
interface PedidoRepositorio {
  salvar(pedido: Pedido): Promise<void>;
}

class RegistrarPedido {
  private readonly repositorio: PedidoRepositorio;

  constructor(repositorio: PedidoRepositorio) {
    this.repositorio = repositorio;
  }

  async executar(pedido: Pedido): Promise<void> {
    if (pedido.totalCentavos < 0) throw new Error("total negativo");
    await this.repositorio.salvar(pedido);
  }
}

// Fake para teste
class RepositorioEmMemoria implements PedidoRepositorio {
  readonly itens: Pedido[] = [];
  async salvar(pedido: Pedido): Promise<void> {
    this.itens.push(pedido);
  }
}

const fake = new RepositorioEmMemoria();
await new RegistrarPedido(fake).executar({ id: "a", totalCentavos: 100 });
console.log("salvos:", fake.itens.length);

O exemplo em Kotlin segue a mesma forma, com interface para a porta e um construtor que recebe a porta. Ele não foi compilado nesta revisão, porque o kotlinc não está no PATH da máquina usada. A versão compilada com Kotlin 2.3.21 está na aula do ArchUnit, citada na seção 5.

interface PedidoRepositorio {
    fun salvar(pedido: Pedido)
}

class RegistrarPedido(private val repositorio: PedidoRepositorio) {
    fun executar(pedido: Pedido) {
        require(pedido.totalCentavos >= 0) { "total negativo" }
        repositorio.salvar(pedido)
    }
}

Todos esses exemplos têm uma peça que ainda não apareceu: quem cria o RepositorioSQL e o entrega ao caso de uso. Essa peça é a raiz de composição. Ela é o único lugar que conhece todas as implementações e as liga entre si, normalmente na inicialização da aplicação. Em Go, é o main.go. Em Spring, é uma classe @Configuration com métodos @Bean. A figura abaixo mostra o papel dela.

composicao
figura 6 · composicao

A raiz de composição aparece à esquerda, num grupo próprio, porque ela tem uma função diferente das outras peças. Ela cria o adaptador JDBC, cria o caso de uso e cria o controller, nessa ordem de dependência. As setas tracejadas que saem do núcleo para a porta mostram que o caso de uso só recebe a porta e nunca a implementação. A raiz é a única peça que pode violar a regra de direção, porque precisa enxergar tudo para montar o grafo. Por isso ela fica fora da regra de pacotes, como uma exceção declarada. Quem tentar colocar essa montagem dentro de um adaptador vai descobrir que o adaptador passa a conhecer os outros adaptadores, e a regra quebra.

7. testes com fakes

A principal vantagem prática da arquitetura aparece na hora de testar. Como o caso de uso depende de uma porta, o teste pode trocar a implementação real por um fake, que é uma implementação simples e funcional guardada em memória. O exemplo em Go da seção 6 já mostra isso: o RepositorioEmMemoria aceita pedidos e os guarda numa fatia, sem nenhuma rede ou banco. O teste da regra “total negativo” roda em microssegundos, e o teste do caminho feliz confirma que o pedido chegou ao repositório.

Fake não é o mesmo que mock. Um fake tem comportamento real, só que simplificado, e o teste confere o estado final. Um mock é configurado para responder de certo jeito e verifica que foi chamado da forma esperada. Os dois são úteis, mas o fake costuma gerar testes menos frágeis, porque eles sobrevivem a uma mudança na forma como o caso de uso chama o repositório, desde que o resultado continue igual. Um mock que espera exatamente uma chamada a Salvar quebra na primeira refatoração, mesmo que o comportamento tenha ficado correto.

A figura abaixo mostra os dois tipos de teste na mesma arquitetura, e a diferença entre eles é o que fica do outro lado da porta.

testes fakes
figura 7 · testes fakes

O grupo de cima é o teste unitário. Ele usa o caso de uso real, que é código de produção, e troca a porta por um fake em memória, marcado em roxo porque ainda é a porta que o núcleo enxerga. O grupo de baixo é o teste de integração. O caso de uso sai de cena e o teste fala com o adaptador JDBC de verdade, que por sua vez fala com um PostgreSQL efêmero, provavelmente subido por Testcontainers. Os dois testes são úteis e respondem perguntas diferentes. O primeiro responde se a regra de negócio está certa. O segundo responde se a tradução para SQL está certa. A aula sobre Testes com Kotest, MockK e Testcontainers mostra como escrever o segundo tipo em Kotlin.

Há uma consequência que costuma surpreender. Quando a arquitetura está bem feita, a maior parte dos testes de regra não precisa de Spring, de banco ou de rede, e isso reduz o tempo da suíte inteira. Quando ela não está bem feita, o teste unitário de uma regra simples precisa subir o contexto do framework, e o desenvolvedor aprende a não escrever testes de regra. A arquitetura não garante bons testes, mas tira o obstáculo que impede escrevê-los.

8. erro técnico na borda

A pergunta que mais revela a qualidade de uma fronteira é: o que acontece quando o banco cai? Numa arquitetura sem portas, a SQLException sobe pela pilha e chega ao controller, que passa a precisar saber o que é SQL para responder com a mensagem certa. A regra de negócio, por sua vez, acaba decidindo com base em códigos de erro do driver. Na arquitetura hexagonal, a tradução acontece na borda. O adaptador captura o erro técnico e devolve um erro expresso na linguagem do núcleo.

erro na borda
figura 8 · erro na borda

A figura segue o caminho do erro de trás para a frente. O PostgreSQL recusa a conexão, e a caixa do banco fica vermelha com a animação de falha. A seta de SQL que deveria passar foi barrada, e isso é representado pelo símbolo de bloqueio. O adaptador JDBC captura a exceção técnica e lança ErroPersistencia, que é uma categoria do núcleo, e não uma classe do driver. A partir daí, a seta vermelha continua pela porta e pelo caso de uso, sempre carregando o erro do núcleo. O controller recebe um erro que ele sabe interpretar e responde 503, sem vazar o stack trace para o cliente. Quem está mais longe do banco nunca vê a palavra SQL.

Esse é o mesmo mecanismo que a saída de Go mostrou na seção 6, onde errors.Is(err, ErrPersistencia) era verdadeiro mesmo com a mensagem original contendo “conexão recusada”. A mensagem técnica continua disponível para o log, mas a decisão de negócio usa a categoria. Isso tem um custo: o adaptador precisa conhecer quais erros do driver são de persistência e quais são de programação, e essa classificação precisa ser feita com cuidado. Um erro de sintaxe SQL é um bug, e não deveria virar uma indisponibilidade para o usuário. O núcleo recebe a categoria, e a borda decide a categoria.

9. comparação com outras arquiteturas

A arquitetura em camadas é a alternativa mais comum e a que muitos times já conhecem. Ela organiza o sistema em uma pilha: interface do usuário, lógica de negócio e acesso a dados. A camada de cima chama a de baixo, e as dependências descem até o banco. O problema não é a organização em si, que é útil para entender um sistema, mas a direção das dependências. Como a lógica de negócio chama diretamente a camada de dados, qualquer mudança no banco pode exigir mudanças na lógica, e testar a lógica costuma exigir o banco ou um mock pesado.

A tabela resume a diferença. Ela compara os dois modelos em pontos que aparecem no dia a dia, e não tenta ser uma lista de virtudes.

PerguntaEm camadasHexagonal
Quem depende de quemA lógica depende da camada de dadosA lógica define a porta, e o adaptador depende dela
Onde fica o bancoAbaixo da lógica, como alicerceNa borda, como um detalhe trocável
Como se testa a regraCom o banco, ou com mocks que imitam o bancoCom um fake da porta, sem infraestrutura
Custo inicialBaixoMais alto, por causa das portas e da raiz de composição
Quando compensaSistemas pequenos e CRUD diretoRegras de negócio que mudam mais que a tecnologia

Clean Architecture é a proposta de Robert C. Martin, apresentada em seu blog em 2012, que reúne ideias parecidas com um vocabulário próprio. Ela tem círculos com entidades, casos de uso, adaptadores de interface e frameworks, e tem uma regra de dependência explícita: as dependências de código só podem apontar para dentro. Na prática, a Clean Architecture e a hexagonal ensinam a mesma regra com nomes e desenhos diferentes. Quem já leu uma delas reconhece a outra em poucos minutos.

Onion Architecture é a proposta de Jeffrey Palermo, de 2008, e também usa círculos concêntricos com o domínio no centro. A diferença é mais de ênfase do que de conteúdo. O guia do ArchUnit trata as duas como o mesmo padrão e oferece uma única API, onionArchitecture(), para verificar as duas, como a aula sobre Arquitetura hexagonal com ArchUnit mostra. Por isso, quando alguém disser “cebola” numa conversa, vale perguntar se a pessoa quer dizer a mesma coisa que hexagonal ou algo diferente.

Dois detalhes de nomenclatura ajudam a ler a literatura sem confusão. O primeiro é que a Clean Architecture aplicada a Go tem uma aula própria, em Clean Architecture em Go, que parte das mesmas regras de dependência. O segundo é que a palavra “porta” se refere à interface, e não à rota HTTP. Um endpoint REST não é uma porta. A porta é o contrato que o caso de uso oferece, e o endpoint é um adaptador que a expõe.

10. erros comuns e boas práticas

O primeiro erro é deixar um tipo do framework escapar pela porta. Uma porta que retorna *sql.Rows, uma entidade anotada com @Entity ou um ResponseEntity do Spring transforma o núcleo em cliente da tecnologia, mesmo que a interface pareça limpa. A boa prática é que a porta use apenas tipos do domínio e tipos primitivos. Quando o adaptador precisa de algo extra, como uma paginação com cursor específico do banco, ele traduz esse detalhe antes de chegar à porta.

O segundo erro é criar uma porta por tabela, com métodos CRUD genéricos. O resultado é um repositório que parece uma tabela em forma de classe, e o caso de uso passa a orquestrar consultas em vez de expressar o negócio. A boa prática é que a porta tenha os verbos do caso de uso, como Salvar, BuscarPendentesDoCliente ou MarcarComoPago, e não Insert, Update e Select.

O terceiro erro é o oposto: criar interfaces para tudo, mesmo sem um segundo uso. Uma interface com uma única implementação e nenhum fake não traz benefício de isolamento, só cerimônia. Isso tem nome, overengineering, e é um dos motivos mais citados para abandonar a arquitetura. A regra prática é perguntar qual é a dependência que você quer trocar ou isolar nos testes. Se a resposta for “nenhuma”, a porta pode esperar.

O quarto erro é colocar regra de negócio no adaptador. Um controller que calcula desconto, ou um repositório que decide se um pedido está atrasado, espalha a regra por um lugar que os testes do núcleo não enxergam. A boa prática é que o adaptador só traduza e roteie. Toda decisão que você gostaria de testar sem subir infraestrutura pertence ao núcleo.

O quinto erro é confiar na regra sem verificá-la. A direção das dependências é fácil de quebrar com um import distraído, e uma revisão de código pode deixar passar. A boa prática é transformar a regra em teste automatizado, como a figura da seção 5 sugere. Quem fizer isso deve também plantar uma violação de propósito e confirmar que o build falha antes de confiar no verde. Um teste de arquitetura que nunca falhou não prova nada.

O sexto erro é espalhar a raiz de composição. Se cada adaptador cria as suas dependências com new, ou se um service locator global é consultado de dentro do núcleo, a montagem fica invisível e o núcleo volta a depender de tudo. A boa prática é concentrar a montagem num só lugar, como o main.go em Go ou uma classe @Configuration em Spring, e deixar o restante do código receber as dependências por construtor.

11. quando não usar

A arquitetura hexagonal tem custo, e esse custo precisa ser pago por algum benefício. Num CRUD simples, em que a tela lê e grava registros quase sem regra, cada porta é uma camada de código a mais para manter, e o benefício de isolamento raramente aparece. O mesmo vale para scripts de uso único, protótipos que serão descartados em poucas semanas e serviços com uma única tela e um único banco que nunca vão mudar.

O critério mais útil é perguntar onde está o risco. Se o sistema tem regras de negócio que mudam com frequência, muitas integrações e uma equipe que precisa testar essas regras sem infraestrutura, a arquitetura se paga rápido. Se o risco está na tecnologia, como um sistema que precisa escalar um banco específico ou um pipeline de dados, a organização em portas pode ajudar menos do que um desenho orientado à tecnologia. Quando não há regra para proteger, a melhor arquitetura costuma ser a mais simples que ainda deixa o código fácil de ler.

12. exercícios

  1. Pegue um caso de uso que você já tenha escrito e liste todas as dependências dele. Para cada uma, diga se é dirigente ou dirigida, e se ela está definida como porta ou como classe concreta. Se alguma classe concreta aparecer no núcleo, descreva a porta que você criaria para ela.
  2. No exemplo Go da seção 6, adicione um novo método Buscar(id string) (Pedido, error) à porta e um fake correspondente. Depois, escreva um teste que confirme que o caso de uso não muda quando o fake troca de implementação. Explique por que esse teste não precisaria mudar se a porta fosse implementada por um banco real.
  3. Crie um pacote internal/adapter/out/http com um cliente que importe RepositorioSQL. Rode uma verificação de dependências (com ArchUnit, se estiver em Java ou Kotlin, ou com go list -deps em Go) e explique por que o adaptador HTTP não deveria depender de outro adaptador.
  4. Escreva um teste negativo para a regra da seção 5. Plante uma violação, por exemplo um import de framework dentro do domínio, confirme que a verificação falha com uma mensagem clara, e remova a violação. Anote o que a mensagem diz sobre o caminho do arquivo.

13. glossário

Arquitetura hexagonal: estilo em que o núcleo de negócio não depende de detalhes técnicos, e os detalhes dependem do núcleo por meio de portas e adaptadores. Também chamada de Ports and Adapters.

Porta: interface definida pelo núcleo para descrever o que ele precisa do mundo externo, ou o que oferece a ele.

Adaptador: classe que implementa uma porta, ou que chama uma porta, usando uma tecnologia concreta como JDBC, HTTP ou fila.

Adaptador dirigente (driving): adaptador que inicia a conversa com o núcleo, como um controller HTTP, uma linha de comando ou um teste.

Adaptador dirigido (driven): adaptador que o núcleo chama para obter algo, como um banco de dados, um gateway de pagamento ou um relógio.

Caso de uso: classe ou função que expressa uma ação do negócio e coordena o domínio e as portas para realizá-la.

Raiz de composição: parte do código que conhece todas as implementações e as liga entre si, normalmente na inicialização da aplicação.

Fake: implementação simplificada e funcional de uma porta, usada em testes, como um repositório guardado em memória.

Regra de dependência: princípio de que as dependências de código apontam para dentro, em direção ao domínio.

Inversão de Dependência: princípio em que módulos de alto nível e de baixo nível dependem de abstrações, e não um do outro. Ver Inversão de Dependência.

fontes

Consultadas em 23/10/2026:

Executado em 23/10/2026: os exemplos de Go (go1.27.2), Java (JDK 21.0.8) e TypeScript (Node 24.21.0, com remoção de tipos) rodaram, e as saídas citadas no texto são as reais. O exemplo em Kotlin não foi compilado nesta revisão.

Não verificado nesta revisão: as datas de Robert C. Martin (2012) e de Jeffrey Palermo (2008), que vêm do conhecimento de base da área e não de uma página consultada agora. A caracterização de Clean Architecture e Onion Architecture também não foi conferida nos textos originais.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?