Trilha de Kotlin · parte 55 de 59
Arquitetura hexagonal com ArchUnit
Aula da fase 8 da trilha de Kotlin: Como manter o domínio sem dependência de framework e como impedir que a regra seja quebrada no futuro?
avançadoPublicada em · 16 min de leitura
Imagine uma tomada de três pinos que fosse soldada diretamente no aparelho. Funciona, até o dia em que a fiação da casa muda e o aparelho precisa ser aberto para ser ligado de novo. A arquitetura hexagonal propõe o contrário: a regra do negócio fica no centro, e tudo o que muda com frequência, como banco de dados, protocolo HTTP ou fornecedor de pagamento, fica na borda, ligado por uma tomada padronizada. Trocar a tomada não obriga a redesenhar o aparelho. Esta aula explica por que essa separação importa, como ela se traduz em pacotes e dependências num serviço Kotlin com Spring Boot, e como o ArchUnit transforma a regra num teste que quebra o build quando alguém a desrespeita.
Os exemplos foram compilados e executados com Kotlin 2.3.21, Spring Boot 4.1.1 (que gerencia o Spring Framework 7.0.9) e ArchUnit 1.5.1, em JDK 21. A mensagem de violação citada mais abaixo é a saída real do ArchUnit nessa execução.
Vale uma definição logo de início, que a seção 3 detalha. Uma porta é uma interface que o núcleo define para descrever o que precisa do mundo externo, por exemplo “guardar um pedido”. Um adaptador é a classe que implementa essa interface usando uma tecnologia concreta, como JDBC ou uma chamada HTTP.
o mapa da aula
A aula começa pelo problema que a arquitetura resolve: uma regra de negócio que importa o framework fica presa ao framework. Em seguida apresenta a regra de dependência, que determina a direção das setas no código. Depois mostra como portas e adaptadores aparecem em pacotes Kotlin. A seção sobre o ArchUnit explica como escrever a regra como código executável, com a API de arquitetura cebola, que o ArchUnit documenta como sinônimo da hexagonal. Por fim, mostra o que acontece quando a regra é violada, com a mensagem real, e três experimentos que mostram o que a regra protege e o que ela não protege sozinha.
1. o problema: a regra de negócio presa ao framework
Num serviço de pedidos, a regra mais importante costuma ser simples: um pedido só pode ser aprovado se estiver pendente. Essa regra não depende de HTTP, de Spring ou de PostgreSQL. Ela deveria ser verdadeira mesmo que o serviço rodasse num terminal de linha de comando. Na prática, o que acontece com frequência é outro. A classe Pedido ganha uma anotação @Entity do JPA, depois um @Component para ser injetada, e em algum momento um método chama um cliente HTTP “só para validar uma coisa”. Cada uma dessas linhas parece inofensiva. Juntas, elas fazem com que testar a regra exija subir um banco, e que trocar de framework exija reescrever o núcleo.
O custo aparece em três momentos. O primeiro é o teste: a regra só pode ser verificada se o ambiente inteiro estiver de pé, o que torna o ciclo de feedback lento. O segundo é a mudança: uma atualização de versão do framework, que deveria ser um assunto da borda, atinge o núcleo. O terceiro é a leitura: quem chega ao projeto não consegue distinguir, olhando os imports, o que é regra e o que é detalhe técnico.
A arquitetura hexagonal, descrita por Alistair Cockburn com o nome de Ports and Adapters, responde a esse problema com uma regra de direção. O núcleo não conhece a borda. A borda conhece o núcleo. A Clean Architecture, de Robert C. Martin, chama isso de regra de dependência, e a arquitetura cebola usa a mesma ideia com outra geometria. O guia do ArchUnit trata as duas como o mesmo padrão e o apresenta pelo nome de arquitetura cebola, com a hexagonal e o Ports and Adapters como nomes alternativos. A seção 4 mostra essa API em uso.
2. a regra de dependência nas camadas do projeto
O projeto de exemplo tem quatro módulos Gradle, descritos na Aula Gradle Kotlin DSL e multi-módulo. Para esta aula, a verificação foi feita num módulo único, com pacotes que representam os mesmos papéis, porque o ArchUnit enxerga pacotes e não módulos. O domínio, em dominio, guarda o Pedido, o Centavos e os estados. A aplicação, em aplicacao, guarda as portas e o caso de uso RegistrarPedido. Os adaptadores, em adaptadores, guardam o controller HTTP, o repositório JDBC e o gateway de pagamento. A raiz de composição, em composicao, monta tudo.
A regra de dependência manda que as setas de código apontem para dentro. O RegistrarPedido usa a porta PedidoRepositorio, que é uma interface definida no pacote de aplicação. O PedidoJdbcRepositorio, que fica no adaptador, implementa essa interface. Assim, a seta de implementação aponta de fora para dentro: a classe concreta conhece a porta, e a porta não conhece a classe concreta. Essa inversão é a Inversão de Dependência, a letra D do SOLID, aplicada ao nível de pacotes.
A figura abaixo mostra as dependências entre os papéis e a exceção que a raiz de composição representa.
A figura mostra, de um lado, os adaptadores e, de outro, o domínio. Os adaptadores de persistência e de pagamento apontam para as portas com linhas pontilhadas, porque implementam a interface sem depender de nada além dela. O controller chama o caso de uso, e o caso de uso usa as portas e o domínio. A raiz de composição aparece com contorno tracejado e com setas de instanciação para os adaptadores. Essa é a única peça que conhece as implementações concretas, e isso é intencional: alguém precisa conhecer as implementações para ligá-las. O domínio não aponta para nenhum outro papel.
Um detalhe do código mostra a regra em ação. A classe Pedido do domínio não importa nada do framework. A única coisa que ela sabe sobre o mundo externo é que o Centavos não pode ser negativo, e isso é regra de negócio:
package com.exemplo.pedidos.dominio
@JvmInline
value class Centavos(val valor: Long) {
init {
require(valor >= 0) { "valor não pode ser negativo: $valor" }
}
operator fun plus(outro: Centavos): Centavos = Centavos(valor + outro.valor)
}
O @JvmInline faz do Centavos uma value class, que na JVM é representada pelo valor envolvido na maior parte dos casos. Isso dá a segurança de tipo de um Centavos separado de um Long sem o custo de um objeto extra a cada uso, uma troca que a Fase 1 do currículo cobre.
3. portas e adaptadores em código
A porta é uma interface que expressa uma necessidade do caso de uso, sem nenhum detalhe técnico:
package com.exemplo.pedidos.aplicacao
interface PedidoRepositorio {
fun salvar(pedido: Pedido)
fun buscar(id: String): Pedido?
}
sealed interface ResultadoCobranca {
data object Aprovada : ResultadoCobranca
data class Recusada(val motivo: String) : ResultadoCobranca
}
interface GatewayPagamento {
fun cobrar(pedido: Pedido): ResultadoCobranca
}
O caso de uso recebe as portas no construtor e não sabe quem as implementa:
class RegistrarPedido(
private val repositorio: PedidoRepositorio,
private val pagamento: GatewayPagamento,
) {
fun executar(pedido: Pedido): Pedido {
val pedidoFinal = when (val resultado = pagamento.cobrar(pedido)) {
ResultadoCobranca.Aprovada -> pedido.aprovar()
is ResultadoCobranca.Recusada -> pedido.copy(estado = EstadoPedido.Recusado)
}
repositorio.salvar(pedidoFinal)
return pedidoFinal
}
}
O when sobre o sealed interface é exaustivo. Se um terceiro tipo de resultado fosse acrescentado, o compilador exigiria um novo ramo, e a Fase 1 mostrou essa propriedade para sealed em outro contexto.
O adaptador de persistência implementa a porta com JDBC. Ele conhece o SQL, o driver e o framework, e não expõe nada disso para quem o chama. Este é o código que foi compilado e testado contra um PostgreSQL 17 real:
@Repository
class PedidoJdbcRepositorio(private val jdbc: JdbcClient) : PedidoRepositorio {
override fun salvar(pedido: Pedido) {
jdbc.sql(
"""
insert into pedido (id, total_centavos, estado) values (:id, :total, :estado)
on conflict (id) do update set estado = excluded.estado
""".trimIndent(),
)
.param("id", pedido.id)
.param("total", pedido.total.valor)
.param("estado", pedido.estado.name)
.update()
}
override fun buscar(id: String): Pedido? =
jdbc.sql("select id, total_centavos, estado from pedido where id = :id")
.param("id", id)
.query { rs, _ ->
Pedido(
id = rs.getString("id"),
total = Centavos(rs.getLong("total_centavos")),
estado = EstadoPedido.valueOf(rs.getString("estado")),
)
}
.optional()
.orElse(null)
}
Há dois pontos de atenção nesse código. O primeiro é que o adaptador traduz o estado do domínio para uma string do banco e de volta. Quando o banco guardasse o estado de outra forma, só essa tradução mudaria. O segundo é que o JdbcClient vem do Spring. Isso é aceitável no adaptador e seria um erro no domínio, e a regra do ArchUnit, na seção 4, existe justamente para impedir que essa fronteira seja cruzada sem que ninguém perceba.
A raiz de composição é a classe que faz a ligação. No projeto, ela é uma configuração do Spring que escolhe a implementação de cada porta e monta o caso de uso. O gateway de pagamento real seria um cliente HTTP. Para a verificação, ele é um stub que aprova todo pedido, envolto pelo disjuntor e pelo retry da Aula Resiliência e empacotamento:
package com.exemplo.pedidos.composicao
@Configuration(proxyBeanMethods = false)
class ConfiguracaoCasosDeUso {
@Bean
fun gatewayPagamento(): GatewayPagamento = GatewayPagamentoResiliente(
remota = object : ChamadaPagamentoRemota {
override fun enviar(pedido: Pedido): ResultadoCobranca = ResultadoCobranca.Aprovada
},
retry = Retry.ofDefaults("pagamento"),
disjuntor = CircuitBreaker.ofDefaults("pagamento"),
)
@Bean
fun registrarPedido(
repositorio: PedidoRepositorio,
gateway: GatewayPagamento,
registro: MeterRegistry,
) = RegistrarPedido(repositorio, MetricasGatewayPagamento(gateway, registro))
}
Essa classe fica num pacote separado, composicao, e não dentro de adaptadores. A seção 4 explica por quê, e mostra como o ArchUnit trata essa exceção.
Pergunta de entrevistaO que é arquitetura hexagonal?
É uma arquitetura em que o núcleo, com as regras de negócio, define portas: interfaces escritas no vocabulário do domínio. Adaptadores implementam essas portas para banco, HTTP ou mensageria. A dependência aponta sempre para dentro, então o domínio não conhece Spring nem JPA. Isso deixa a regra testável sem infraestrutura, e a troca de tecnologia fica restrita a um adaptador.
4. o ArchUnit transforma a regra em teste
A regra de dependência só vale se alguém a verificar. Um code review pode deixar passar um import de Spring dentro do domínio, principalmente quando ele está no meio de um diff grande. O ArchUnit é uma biblioteca Java que lê o bytecode das classes e executa regras escritas em código. Ela não depende da leitura humana, e roda dentro do build como qualquer teste JUnit. A versão usada aqui é a 1.5.1, publicada no Maven Central.
O teste de arquitetura do projeto contém duas regras. A primeira proíbe o domínio de depender de frameworks, de adaptadores e da aplicação:
@AnalyzeClasses(packages = ["com.exemplo.pedidos"], importOptions = [ImportOption.DoNotIncludeTests::class])
class RegrasDeArquiteturaTest {
@ArchTest
@JvmField
val dominioNaoConheceFrameworksNemAdaptadores: ArchRule = noClasses()
.that().resideInAPackage("..dominio..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"io.micrometer..",
"io.github.resilience4j..",
"..adaptadores..",
"..aplicacao..",
)
@AnalyzeClasses diz ao ArchUnit quais classes importar. @ArchTest marca uma regra para o JUnit executar. Os nomes de pacote usam .. como curinga de qualquer profundidade, então ..dominio.. pega com.exemplo.pedidos.dominio e qualquer subpacote. O @JvmField aparece no código porque o guia do ArchUnit mostra o campo como val público em Kotlin, e a versão com ele foi a usada na verificação. A seção 5 conta o que aconteceu quando ele foi retirado.
A segunda regra é a cebola. A API onionArchitecture() recebe os pacotes de cada papel e aplica as restrições de acesso. O guia do ArchUnit, na seção 8.1.2, usa “Onion Architecture” como nome principal e diz que o padrão também é conhecido como “Hexagonal Architecture” ou “Ports and Adapters”. Por isso a mesma API serve para as duas arquiteturas:
@ArchTest
@JvmField
val arquiteturaCebola: ArchRule = onionArchitecture()
.domainModels("..dominio..")
.applicationServices("..aplicacao..")
.withOptionalLayers(true)
.adapter("web", "..adaptadores.web..")
.adapter("persistencia", "..adaptadores.persistencia..")
.adapter("pagamento", "..adaptadores.pagamento..")
.ignoreDependency(
JavaClass.Predicates.resideInAPackage("..composicao.."),
DescribedPredicate.alwaysTrue(),
)
}
A regra diz, em linguagem simples, que modelos de domínio não dependem de nada de fora, que serviços de aplicação só dependem do domínio, e que adaptadores podem depender do núcleo, mas não uns dos outros. A exceção do ignoreDependency deixa a raiz de composição de fora da regra. O withOptionalLayers(true) declara que a camada de serviços de domínio pode não existir, o que é o caso deste projeto. Essa opção compilou e o teste passou, mas a afirmação de que sem ela surgiria uma violação falsa não foi testada nesta revisão.
O ArchUnit tem uma escolha de API que merece atenção. A exceção foi escrita com predicados, JavaClass.Predicates.resideInAPackage(...), e não com texto. A forma com texto não foi testada nesta revisão, então a aula não compara as duas.
A figura abaixo mostra o caminho da regra, do commit até o build.
O olhar começa no commit, passa pelo ./gradlew test, e chega a um ponto de decisão. Quando a dependência é permitida, o build segue verde. Quando não é, o ArchUnit gera uma violação que nomeia a classe e a anotação proibida. A parte mais útil é a mensagem, porque aponta o lugar exato da quebra sem que alguém precise procurar.
Esse caminho foi testado de verdade. Para verificar que a regra não era decorativa, criei temporariamente a classe VazamentoDeFramework no pacote dominio, anotada com @Component do Spring, e rodei o teste de arquitetura. A saída real do Maven, sem nenhuma edição, foi:
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..dominio..' should depend on classes that reside in any package ['org.springframework..', 'io.micrometer..', 'io.github.resilience4j..', '..adaptadores..', '..aplicacao..']' was violated (1 times):
Class <com.exemplo.pedidos.dominio.VazamentoDeFramework> is annotated with <org.springframework.stereotype.Component> in (VazamentoDeFramework.kt:0)
Depois de remover o arquivo e limpar o diretório de classes compiladas, o teste voltou a passar. A limpeza importa: uma execução sem mvn clean manteve o .class antigo em target/classes e fez a regra falhar mesmo depois da remoção do código-fonte. Essa é a diferença entre uma regra escrita e uma regra verificada, e também uma lição sobre o que está no classpath de build.
5. três experimentos sobre a regra
A verificação desta aula fez três experimentos que mudam o que a aula afirma.
O primeiro foi o teste negativo, descrito na seção anterior. A regra pegou a violação plantada, e a mensagem nomeia a classe.
O segundo foi a remoção do @JvmField. Sem o @JvmField, o teste também pegou a violação, e a regra não foi ignorada em silêncio. Com a violação plantada e sem @JvmField, o teste falhou da mesma forma, com a mesma mensagem. Nesta combinação de Kotlin 2.3.21 e ArchUnit 1.5.1, o campo val foi executado sem o modificador. O @JvmField continua no código porque o guia o mostra assim, mas a aula não sustenta mais que ele é obrigatório. Se a sua combinação de versões ignorar a regra, o teste negativo é a forma de descobrir, e é a prova que vale.
O terceiro foi a exceção da composição. O teste de arquitetura passou com a raiz de composição fora de adaptadores e com a exceção declarada por predicados. Não foi testado o caso em que a composição fica dentro do pacote de adaptadores, e por isso a aula não afirma o que acontece nele. O que se pode dizer com segurança é que a exceção existe, que o código compila com ela, e que o teste passa.
Há também uma lição de processo. Um teste de arquitetura que passa precisa ser comparado com um teste que falha. Sem a violação plantada, o verde não diz nada sobre a regra.
6. erros comuns e boas práticas
O erro mais frequente é transformar a regra em uma lista de intenções e não em código. “O domínio não deve depender de Spring” é uma frase que todo mundo concorda e que ninguém verifica. A prática recomendada é escrever a frase como noClasses()...should()... no mesmo repositório, de modo que a violação quebre o build.
O segundo erro é colocar a raiz de composição dentro de um adaptador. Parece organizado, porque ela monta adaptadores, mas ela precisa de acesso a tudo, e isso a torna incompatível com a regra cebola. A boa prática é um pacote próprio, declarado como exceção.
O terceiro erro é confiar num teste de arquitetura que nunca falhou. A boa prática é planta uma violação, confirmar que o build quebra com a mensagem esperada, e remover a violação. Isso vale principalmente depois de mudanças na configuração do ArchUnit, como a troca de versão do Kotlin ou da biblioteca.
O quarto erro é deixar .class antigos em target/classes durante um experimento. A regra falha por um motivo que já não existe no código-fonte, e o diagnóstico vira uma caça ao fantasma. A boa prática é mvn clean ou ./gradlew clean antes de cada experimento.
O quinto erro é a regra que quebra por motivos que não existem na arquitetura, como uma camada vazia. A regra deve descrever a estrutura real, e withOptionalLayers existe para isso quando a estrutura varia entre projetos.
7. exercícios
- Crie um pacote
com.exemplo.pedidos.adaptadores.web.internocom uma classe que importePedidoJdbcRepositorio. Rode o teste de arquitetura e leia a mensagem. Explique por que a regra da cebola protege o adaptador web de depender de outro adaptador. - Remova o
ignoreDependencyda regra, rode o teste e conte quantas violações aparecem. Agrupe-as por tipo e explique por que a composição é a fonte dessas violações. - Escreva uma regra ArchUnit que proíba classes do pacote
..aplicacao..de usarjava.sql... Planta uma violação, confirme a falha, limpe o build e remova a violação. - Repita o teste negativo sem
@JvmFielde commvn cleanantes. Se a regra continuar falhando, a sua combinação de versões se comporta como a desta aula. Se ela passar em silêncio, registre a versão do Kotlin e do ArchUnit e descreva o que isso significa para a regra.
8. 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.
Arquitetura cebola: nome principal que o guia do ArchUnit usa para o mesmo padrão, com as camadas dispostas em círculos concêntricos.
Porta: interface definida pelo núcleo para descrever o que ele precisa do mundo externo.
Adaptador: classe que implementa uma porta com uma tecnologia concreta, como JDBC, HTTP ou uma fila.
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.
Regra de dependência: princípio de que as dependências de código apontam para dentro, em direção ao domínio.
ArchUnit: biblioteca Java que lê bytecode e executa regras de arquitetura como testes.
@ArchTest: anotação que marca uma regra ArchUnit para ser executada pelo JUnit.
onionArchitecture(): API do ArchUnit para a arquitetura cebola, usada aqui para as duas arquiteturas.
fontes
Consultadas em 20/10/2026.
- Guia do usuário do ArchUnit, seção 3.4 (uso do JUnit com Kotlin, com
@ArchTestemval): https://www.archunit.org/userguide/html/000_Index.html - Guia do usuário do ArchUnit, seção 8.1.2 (arquitetura cebola, também chamada de hexagonal ou Ports and Adapters): mesma página
- Versão 1.5.1 publicada no Maven Central: https://repo1.maven.org/maven2/com/tngtech/archunit/archunit-junit5/maven-metadata.xml
Não verificado nesta revisão: a seção do guia sobre FreezingArchRule, e a conceituação histórica de Cockburn e Martin, que vem do conhecimento de base da área e não de uma página consultada nesta revisão.
Executado em 20/10/2026, em projeto Maven de verificação com Kotlin 2.3.21, Spring Boot 4.1.1 e ArchUnit 1.5.1, JDK 21: a regra cebola e a regra do domínio passaram com a composição fora dos adaptadores. A violação plantada foi detectada e a mensagem real está na seção 4. O experimento sem @JvmField também foi detectado. A execução foi feita com Maven, não com Gradle.