Pular para o conteúdo

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

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

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.

Dependências da arquitetura hexagonal. PedidoController → RegistrarPedido (chama); RegistrarPedido → Portas (usa); PedidoJdbcRepositorio → Portas (implementa)…
figura 1 · Dependências da arquitetura hexagonal

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.

Regra de arquitetura no build. Commit no domínio → ./gradlew test (build local ou CI); ./gradlew test → Dependência proibida? (ArchUnit percorre o bytecode)…
figura 2 · Regra de arquitetura no 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

  1. Crie um pacote com.exemplo.pedidos.adaptadores.web.interno com uma classe que importe PedidoJdbcRepositorio. 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.
  2. Remova o ignoreDependency da 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.
  3. Escreva uma regra ArchUnit que proíba classes do pacote ..aplicacao.. de usar java.sql... Planta uma violação, confirme a falha, limpe o build e remova a violação.
  4. Repita o teste negativo sem @JvmField e com mvn clean antes. 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.

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.

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
?