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 umalayeredArchitecture()equivalente) rodando viamvn test. - O relatório de falha:
mvn -B testsai com código 1,Tests run: 2, Failures: 2, e oAssertionErrorlista as três violações — construtor, campo, chamada de método — todas apontando deacademy.devdojo.shop.domain.Orderparaacademy.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:
| Componente | Versão |
|---|---|
| JDK | Temurin 25.0.4 (Java 25 LTS) |
| Maven | 3.9.12 |
| ArchUnit | 1.5.0 (com.tngtech.archunit:archunit-junit6) |
| JUnit Platform (via ArchUnit) | 6.1.2 |
| maven-compiler-plugin | 3.14.1, <release>25</release> |
| maven-surefire-plugin | 3.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:
- Origem — a classe que não deveria depender de nada:
academy.devdojo.shop.domain.Order. - Destino — a classe proibida:
academy.devdojo.shop.adapter.persistence.OrderStore. - 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:0porque 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
OrderStorenoOrdere nunca referenciar o tipo, ojavacdescarta 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 campoObjectouMapque 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 umintou 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 fazplus()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.