Todos os artigos

// Knowledge.log — 技術記事

ArchUnit no Maven: a dependência ilegal que o code review não viu

Uma regra ArchUnit no Maven barra uma dependência ilegal de domain para adapter que passou pelo code review, com Java 25 e JUnit 6, sem Spring.

O PR foi aprovado. Compilou, os testes de negócio passaram, ninguém comentou nada na revisão. Só que a classe Order, que deveria ser um objeto de domínio puro, ganhou um campo do tipo OrderStore — a classe que persiste o pedido. Domain dependendo de adapter, ao contrário do que qualquer diagrama de camadas do time diz. O diagrama vive numa página de wiki que ninguém abre desde o onboarding. O javac não tem opinião sobre isso, e o revisor, olhando só o diff, viu um construtor recebendo uma dependência e seguiu em frente.

Este artigo mostra como transformar essa regra de arquitetura — "domain não depende de adapter" — em um teste que o Maven executa e que falha o build com nome de classe de origem, nome de classe de destino e o trecho de bytecode que violou a regra. Sem Spring Boot, sem contêiner, só Java 25, Maven e ArchUnit.

O que você vai verificar

  • Uma regra noClasses().dependOnClassesThat() (e uma layeredArchitecture() equivalente) rodando via mvn test.
  • O relatório de falha: mvn -B test sai com código 1, Tests run: 2, Failures: 2, e o AssertionError lista as três violações — construtor, campo, chamada de método — todas apontando de academy.devdojo.shop.domain.Order para academy.devdojo.shop.adapter.persistence.OrderStore.
  • A correção — tirar o tipo do adapter do domínio — e o rerun com Tests run: 2, Failures: 0.
  • O que essa regra não pega, e onde ela para de fazer sentido.

Pré-requisitos e versões

Conjunto verificado neste host em 2026-09-21, sem RC, milestone ou snapshot:

ComponenteVersão
JDKTemurin 25.0.4 (Java 25 LTS)
Maven3.9.12
ArchUnit1.5.0 (com.tngtech.archunit:archunit-junit6)
JUnit Platform (via ArchUnit)6.1.2
maven-compiler-plugin3.14.1, <release>25</release>
maven-surefire-plugin3.5.6

Um detalhe que vale declarar porque engana quem só olha a home do projeto: a página inicial do ArchUnit ainda lista a v1.4.2 no widget de notícias. O Maven Central e a tag do GitHub dizem outra coisa — v1.5.0, lançada com suporte a JDK 25 e ao archunit-junit6. É o tipo de página que fica desatualizada e ninguém sente pressa de corrigir, porque "todo mundo já sabe" — até o próximo PR que copia a versão errada de lá.

archunit-junit6 é o artefato de conveniência: API + engine de testes + cache de classes importadas entre regras, seguindo o mesmo split que o Jupiter usa para JUnit 5/6. Ele traz junit-platform-engine 6.1.2 como transitiva — não é preciso adicionar junit-jupiter nem um BOM de JUnit para usar @AnalyzeClasses/@ArchTest. O caminho mais antigo, archunit-junit5 sobre JUnit 5.14.x, continua documentado no User Guide oficial e ainda aparece em projetos que migraram há menos tempo; ele serve aqui só como referência de migração, não como o exemplo — o próprio guia trata archunit-junit6 como a instalação padrão.

O projeto mínimo

Três pastas, dois pacotes, uma dependência de teste.

src/main/java/academy/devdojo/shop/domain/Order.java
src/main/java/academy/devdojo/shop/adapter/persistence/OrderStore.java
src/test/java/academy/devdojo/shop/ArchitectureTest.java

pom.xml com uma única dependência de teste:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>academy.devdojo</groupId>
    <artifactId>archunit-demo</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>archunit-demo</name>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>25</maven.compiler.release>
        <archunit.version>1.5.0</archunit.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit6</artifactId>
            <version>${archunit.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.14.1</version>
                <configuration>
                    <release>25</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.5.6</version>
            </plugin>
        </plugins>
    </build>
</project>

Nenhum junit-bom, nenhum junit-jupiter avulso. O Surefire detecta o JUnitPlatformProvider sozinho a partir da dependência do ArchUnit.

A regra: domain não depende de adapter

package academy.devdojo.shop;

import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "academy.devdojo.shop", importOptions = ImportOption.DoNotIncludeTests.class)
public class ArchitectureTest {

    @ArchTest
    static final ArchRule domain_must_not_depend_on_adapter =
            noClasses().that().resideInAPackage("..domain..")
                    .should().dependOnClassesThat().resideInAPackage("..adapter..");

    @ArchTest
    static final ArchRule adapter_must_not_be_accessed_from_domain =
            layeredArchitecture().consideringAllDependencies()
                    .layer("Domain").definedBy("academy.devdojo.shop.domain..")
                    .layer("Adapter").definedBy("academy.devdojo.shop.adapter..")
                    .whereLayer("Adapter").mayNotBeAccessedByAnyLayer();
}

Duas formas para a mesma ideia. A primeira é uma regra pontual: nada em ..domain.. pode depender de nada em ..adapter... A segunda é layeredArchitecture(), pensada para quando você tem várias camadas e quer declarar o mapa inteiro de uma vez — a partir da 1.5.0 exige .consideringAllDependencies() antes de .layer(...).

Repare no verbo: dependOnClassesThat, não accessClassesThat. accessClassesThat só enxerga chamadas de método e acesso a campo; ele não vê um parâmetro de construtor guardado num campo que nunca é lido, nem um tipo usado só como parâmetro de método que nunca é chamado dentro da classe analisada. dependOnClassesThat cobre campo, parâmetro de construtor, parâmetro de método, tipo de retorno e herança — a rede mais larga é a que você quer numa regra de camada.

@AnalyzeClasses varre o pacote em bytecode (não em código-fonte) e ImportOption.DoNotIncludeTests.class evita que a própria classe de teste apareça na análise, o que geraria ruído — ela normalmente depende de meio projeto.

A dependência ilegal

package academy.devdojo.shop.domain;

import academy.devdojo.shop.adapter.persistence.OrderStore;

public final class Order {
    private final OrderStore store;

    public Order(OrderStore store) {
        this.store = store;
    }

    public void persist(String orderId) {
        store.save(orderId);
    }
}
package academy.devdojo.shop.adapter.persistence;

public final class OrderStore {
    public void save(String orderId) {
        // no-op: demo só de bytecode, sem JDBC/Spring
    }
}

Nada aqui grita "isso é grave". Order compila, persist funciona, o teste de unidade que só chama persist("123") passa tranquilo. É exatamente o tipo de mudança que sobrevive a um code review de dez minutos: o revisor vê um construtor recebendo uma dependência a mais e segue lendo, porque isso é o que construtores fazem.

Rodando mvn -B test:

Tests run: 2, Failures: 2, Errors: 0, Skipped: 0, Time elapsed: 0.830 s <<< FAILURE! -- in academy.devdojo.shop.ArchitectureTest
academy.devdojo.shop.ArchitectureTest.domain_must_not_depend_on_adapter -- Time elapsed: 0.760 s <<< FAILURE!
java.lang.AssertionError: 
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..domain..' should depend on classes that reside in a package '..adapter..'' was violated (3 times):
Constructor <academy.devdojo.shop.domain.Order.<init>(academy.devdojo.shop.adapter.persistence.OrderStore)> has parameter of type <academy.devdojo.shop.adapter.persistence.OrderStore> in (Order.java:0)
Field <academy.devdojo.shop.domain.Order.store> has type <academy.devdojo.shop.adapter.persistence.OrderStore> in (Order.java:0)
Method <academy.devdojo.shop.domain.Order.persist(java.lang.String)> calls method <academy.devdojo.shop.adapter.persistence.OrderStore.save(java.lang.String)> in (Order.java:14)

A segunda regra, adapter_must_not_be_accessed_from_domain, falha com o mesmo trio de fatos, só que embrulhado no vocabulário de camadas. Build sai com código de saída 1, mvn marca BUILD FAILURE. Duas regras, um problema só, reportado duas vezes — o que é ótimo quando você quer que qualquer um dos dois testes já bloqueie o merge, e um pouco redundante se o objetivo é ler o relatório rapidamente. Escolha uma das duas para o dia a dia; manter as duas juntas aqui é só para mostrar que dizem a mesma coisa com formulações diferentes.

Lendo o relatório

O AssertionError tem uma estrutura fixa, e vale decorar as três perguntas que ele responde:

  1. Origem — a classe que não deveria depender de nada: academy.devdojo.shop.domain.Order.
  2. Destino — a classe proibida: academy.devdojo.shop.adapter.persistence.OrderStore.
  3. Como — três fatos de bytecode, não de código-fonte: um parâmetro de construtor, um campo, uma chamada de método. Os dois primeiros aparecem como Order.java:0 porque campo e parâmetro de construtor não carregam número de linha na tabela de depuração; a chamada de método aparece com a linha real, Order.java:14.

Não é um "algo está errado em algum lugar do domínio". É origem, destino e o ponto exato de acoplamento — o suficiente para abrir o arquivo certo sem precisar de git blame para achar quem introduziu o quê.

A correção

Order volta a ser o que devia: um objeto de valor com um id, sem saber que existe um OrderStore.

package academy.devdojo.shop.domain;

public final class Order {
    private final String id;

    public Order(String id) {
        this.id = id;
    }

    public String id() {
        return id;
    }
}
package academy.devdojo.shop.adapter.persistence;

import academy.devdojo.shop.domain.Order;

public final class OrderStore {
    public void save(Order order) {
        // no-op: demo só de bytecode, sem JDBC/Spring
    }
}

OrderStore agora depende de Order — e isso é permitido pela regra: adapter pode depender de domain, o inverso é que é proibido. É a direção da seta que importa, não a existência da relação.

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.795 s -- in academy.devdojo.shop.ArchitectureTest

mvn -B test sai com código 0, BUILD SUCCESS. Se aparecer um aviso do SLF4J dizendo que não achou um binding (NOP logger), ignore — é ruído de dependência transitiva do ArchUnit, não falha de teste. Confundir esse warning com um problema real é um jeito rápido de perder dez minutos procurando um bug que não existe.

O que essa regra não pega

ArchUnit importa bytecode e verifica estrutura — quem depende de quem, quem chama o quê, quem estende o quê. Ele não executa o método de produção. Vale listar onde essa fronteira dói na prática:

  • Import não usado. Se alguém importar OrderStore no Order e nunca referenciar o tipo, o javac descarta o import na hora de gerar o .class. Sem tipo no bytecode, sem violação — mesmo que o import "pareça" ilegal na revisão visual do diff.
  • Wiring via reflexão ou nome de bean. Class.forName, injeção por nome de string, um campo Object ou Map que o framework preenche em runtime com uma instância de adapter — nada disso deixa uma aresta de tipo no bytecode que o ArchUnit consiga importar. Um campo tipado, como no exemplo acima, é visível; uma referência que só existe em runtime, não.
  • Bug de comportamento. Uma regra de arquitetura não sabe se Order.plus() soma errado, estoura um int ou arredonda para o lado errado. Isso é falha de lógica, não de dependência — e é exatamente o tipo de caso que os testes baseados em propriedade cobrem, como no artigo sobre jqwik com um caso de soma quebrada, onde o gerador de entradas encontra o valor que faz plus() falhar. São dois modos de falha diferentes: violação de arquitetura com nomes de classe versus propriedade falsificada com uma amostra reduzida. ArchUnit não substitui esse tipo de teste, cobre outro eixo.

Correção e observação em produção

Para cada violação, o remédio é sempre o mesmo movimento: inverter ou remover a dependência até que só o adapter conheça o domínio, nunca o contrário. O que muda é onde você percebe isso antes de virar incidente.

A observação aqui não precisa de métrica nova nem de contador do Micrometer — é o próprio pipeline de build. Rode mvn test (ou mvn verify) em CI a cada PR; o relatório do Surefire para ArchitectureTest já traz classe de origem, classe de destino e ponto de bytecode quando alguém reintroduzir o acoplamento. Nomeie a classe de teste terminando em Test — é o padrão que o Surefire inclui por default; um nome como ArchitectureRules simplesmente não roda nenhum teste, e o silêncio no relatório é fácil de confundir com sucesso.

Recomendação

Vale a pena adotar noClasses().dependOnClassesThat() (ou layeredArchitecture(), se as camadas já estão bem definidas) assim que o projeto tiver mais de um pacote com regra de dependência que hoje só existe em documento ou na cabeça de quem desenhou a arquitetura. O custo é uma dependência de teste e uma classe curta; o retorno é um BUILD FAILURE com nome de classe em vez de uma descoberta tardia em produção ou numa reunião de arquitetura três meses depois.

Recue se o módulo for pequeno o bastante para caber inteiro na cabeça de quem revisa, ou se as camadas ainda estão mudando de forma toda semana — nesse estágio a regra vira manutenção constante do teste, não proteção real. E não trate isso como substituto de teste de comportamento: ArchUnit garante que a seta aponta na direção certa; se o que está na ponta da seta calcula errado, isso é outro teste, com outra ferramenta.

javatesting

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos