A hexagonal architecture has a few hard rules: the core (core/) must know nothing about Spring or the database; ports are interfaces only; inbound adapters don't depend on outbound ones. These rules are easy to break by accident — a single import in the wrong place is enough. And even an attentive reviewer can miss it in a large PR.
The solution is to make the rules automatically executable. ArchUnit is a library that lets you write architecture rules as ordinary JUnit tests. A broken rule = a failing test = a blocked PR.
Why code review is not enough
It seems like it should be enough to agree as a team and look at PRs carefully. In practice this doesn't work reliably:
- In a large PR with 30 files, one stray
import org.springframework.*in the core is easily lost. - There are dependency chains: class A depends on B, B on C, C on Spring. In a specific commit this looks like "we added B to A", and review misses it.
- A new developer doesn't know all the rules — code review transfers knowledge slowly. ArchUnit gives instant feedback right in CI: "your PR breaks a rule".
- Code review allows agreements like "it's fine for now, we'll fix it later". ArchUnit either passes or it doesn't — no exceptions by agreement.
ArchUnit doesn't replace code review, it complements it. Review looks at design, readability, business logic. ArchUnit looks at architecture invariants. These are different planes.
Where to place the tests
The typical place is bootstrap/src/test/java/:
bootstrap/
└── src/test/java/<pkg>/architecture/
├── HexagonalArchitectureTest.java # main file with the rules
├── CoreLayerTest.java
├── PortTest.java
├── AdapterTest.java
└── ControllerTest.java
Why specifically in bootstrap/:
bootstrap/depends on all the other modules, so its test-classpath contains classes fromcore/,persistence/, and all the adapters — ArchUnit can check them.bootstrap/already has JUnit and the test infrastructure, so adding ArchUnit breaks nothing.
An alternative is a separate gradle module architecture-tests/ that depends on everything else. This is cleaner, but it adds one more module. In practice, placing them in bootstrap/ takes root more easily.
What to check
Here is a full set of rules for a hexagonal service:
@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
@ArchTest
static final ArchRule coreShouldNotDependOnSpring =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.springframework..");
@ArchTest
static final ArchRule coreShouldNotDependOnJooq =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.jooq..");
@ArchTest
static final ArchRule coreShouldNotDependOnJackson =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("com.fasterxml.jackson..");
@ArchTest
static final ArchRule coreShouldNotDependOnHttpClients =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAnyPackage(
"okhttp3..", "retrofit2..", "feign..", "org.springframework.web.client..");
@ArchTest
static final ArchRule coreShouldNotDependOnKafka =
noClasses().that().resideInAPackage("..core..")
.should().dependOnClassesThat().resideInAPackage("org.apache.kafka..");
@ArchTest
static final ArchRule portsInCoreShouldBeInterfaces =
classes().that().resideInAPackage("..core..port.out..")
.should().beInterfaces();
@ArchTest
static final ArchRule inAdapterShouldNotDependOnOutAdapter =
noClasses().that().resideInAnyPackage("..userapi..", "..adminapi..", "..kafkain..")
.should().dependOnClassesThat().resideInAnyPackage(
"..persistence..", "..sberout..", "..smsout..", "..kafkaout..");
@ArchTest
static final ArchRule outAdaptersShouldImplementPorts =
classes().that().resideInAPackage("..sberout..")
.and().areAnnotatedWith(Component.class)
.should().implement(JavaClass.Predicates.resideInAPackage("..core..port.out.."));
@ArchTest
static final ArchRule controllersShouldImplementGeneratedApi =
classes().that().areAnnotatedWith(RestController.class)
.should().beAssignableTo(JavaClass.Predicates.resideInAPackage("..api.generated.."));
}
What is being checked here and why:
core does not depend on Spring, JOOQ, Jackson, HTTP clients, Kafka — the core contains only business logic in plain Java. Spring annotations, SQL queries, HTTP calls — these are infrastructure details that live in the adapters.
Ports in core/ are interfaces only — a port is a contract between the core and the outside world. The implementation of the contract always lives outside the core, in an adapter. If a port is a class, the boundary is blurred.
Inbound adapters do not depend on outbound ones — userapi, adminapi, kafkain must not import persistence, sberout, and the like directly. They communicate through the core and its ports.
Outbound adapters implement a port — every @Component in an outbound adapter must implement an interface from core/port/out/. This guarantees that the contract and the implementation are linked.
Controllers implement the generated API — a controller must implement the interface generated from the OpenAPI specification. This keeps the code and the contract from drifting apart.
How to add it to CI
The test runs as an ordinary JUnit test, so it's enough to include it in the standard run:
# .github/workflows/ci.yml
jobs:
arch-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: 21
- run: ./gradlew :bootstrap:test --tests "*HexagonalArchitectureTest*"
An important step is to make this job required in the branch protection rules. Without that, a developer can open a PR without running the test locally, and if the reviewer misses it too, the violation lands in main.
When the test is required, you can't merge a PR without a green arch-test. This makes the mistake cheap — caught in CI, not a month later in production.
A single scan point
@AnalyzeClasses should be placed once at the class level, not repeated in every test:
@AnalyzeClasses(packages = "ru.example.order")
public class HexagonalArchitectureTest {
// all rules share a single classpath scan
}
Without this, each rule triggers a fresh classpath scan. On a project with a large number of classes, that's seconds per rule. With a shared scan, it's milliseconds.
How it grows over time
The rule set is a living thing. When some anti-pattern slips through code review once, it gets pinned down with a new test. Over time, the rule set covers everything that has actually happened in the project.
This turns ArchUnit tests into something like recorded lessons: "we stepped on this once — now the test won't let it through". A new developer receives this knowledge automatically, without any special onboarding.
In short
- ArchUnit lets you write architecture rules as JUnit tests — a broken rule is immediately visible in CI.
- The tests are placed in
bootstrap/src/test/java/— that's where access to the classes of all modules is available. - Mandatory rules:
coredoes not depend on Spring/JOOQ/Jackson/HTTP/Kafka; ports are interfaces only; inbound adapters don't depend on outbound ones; outbound adapters implement ports fromcore/. @AnalyzeClassesis placed once per class so the classpath isn't scanned repeatedly.- The test must be made required in CI — otherwise it's easy to bypass.
- ArchUnit is not a replacement for code review but a complement to it: review covers design and logic, ArchUnit covers architecture invariants.
Further reading
- Hexagonal Core Layer — what exactly should (and should not) live in the core.
- Ports in Hexagonal — why a port is an interface, not a class.
- Inbound and Outbound Adapters — how adapters are structured and why they don't depend on each other.