Enforcing Java Architecture Rules with ArchUnit
A Java codebase can look modular in a repository diagram and still be tightly coupled in practice. A controller may import a repository, a domain package may depend on a framework annotation, or two supposedly independent features may call each other. These dependencies compile, pass unit tests and then make future changes riskier than the original design intended.
ArchUnit turns those structural expectations into executable checks. It inspects imported Java types and reports dependency violations using rules that live beside the normal test suite. Instead of relying on a diagram or a code-review convention, a team can make an architectural boundary fail the build when a change crosses it.
The useful idea is architectural fitness: continuously verify properties that are expensive to discover late. These might include layer direction, package cycles, forbidden framework dependencies or naming conventions. ArchUnit does not replace design discussions, but it makes agreed decisions persistent and observable.
The strongest implementations begin with a small set of boundaries tied to real maintenance risks, then expand as the rule vocabulary matures. The examples below use Maven, Gradle and JUnit 5, but the same principles apply to Java services, libraries and batch applications.
Why architecture boundaries drift
Most architecture erosion begins with a reasonable short-term shortcut. A developer reuses an existing utility instead of introducing an abstraction, or a feature package reaches into another feature's internals. Local tests remain green because each shortcut works in isolation. Over time, the dependency graph becomes the de facto architecture, even when that graph differs from the documented one.
Static analysis catches this drift before it becomes institutional. ArchUnit resolves classes from the compiled classpath, examines their dependencies and compares those relationships with declarative rules. A violation produces a test-style failure containing the classes involved, which is far easier for a developer to act on than a vague architecture warning.
Architecture rules should describe consequences instead of preferred folder names. A dependency from a transport package into persistence may threaten testability; a naming suffix may be cosmetic. Teams gain more from a few enforceable invariants that protect design intent than from a long catalogue of stylistic checks nobody trusts.
Adding ArchUnit to a Java build
Add the JUnit 5 integration to the test source set and pin a single approved version. The dependency remains test-only, so it does not leak into the packaged application. A Maven declaration can use the following form, while a Gradle build can declare the equivalent test dependency:
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
testImplementation("com.tngtech.archunit:archunit-junit5:${archunitVersion}")
Define the version in a shared build property or dependency-management file so local runs and CI use the same release. The test class names the application package to scan and declares each rule as a static field:
@AnalyzeClasses(packages = "com.acme.billing")
class ArchitectureTest {
@ArchTest
static final ArchRule controllersDoNotUseRepositories =
noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..repository..");
}
Keep the suite fast. Exclude generated sources and test fixtures through ArchUnit import options when they create noise, and consider a dedicated architecture-test Gradle task or Maven execution for quick feedback. If the main build already runs unit tests, the simplest arrangement is to let the same test phase run both while preserving a separate command for structural checks.
Turning package boundaries into executable rules
Direct dependency rules provide immediate feedback. For example, this version keeps selected framework and persistence packages out of the domain model, with failure messages identifying the forbidden dependency:
@ArchTest
static final ArchRule domainHasNoFrameworkDependencies =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"org.springframework..",
"jakarta.persistence..");
Package cycles need a different rule. When features use a consistent namespace, slices can detect circular dependencies between them:
@ArchTest
static final ArchRule featuresShouldBeFreeOfCycles =
slices()
.matching("com.acme.billing.(*)..")
.should().beFreeOfCycles();
For applications organised by technical layers, the layered API expresses the intended direction more clearly:
@ArchTest
static final ArchRule layersAreIsolated =
layeredArchitecture().consideringAllDependencies()
.layer("Controllers").definedBy("..controller..")
.layer("Services").definedBy("..service..")
.layer("Persistence").definedBy("..repository..")
.whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
.whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services");
When domain types include Java enums, structure and behaviour should be checked together. Pair placement rules with testing Java enums, since an enum in the correct package can still contain invalid state transitions or business logic.
Choosing rules that reflect the design
Not every dependency is harmful, and over-specific rules create friction. A shared date utility may legitimately be used by several layers, while a domain model importing Spring annotations may be a serious leak. The difference is intent: record why each exception exists and attach it to a narrow rule rather than hiding a general violation.
Annotation and naming rules can still be valuable, especially when they enforce public API conventions. They are usually easier to maintain when they describe stable, observable properties rather than implementation details. A rule forbidding all fields named status, for example, is unlikely to age well; requiring public transport types to remain free of persistence types is easier to justify.
Legacy code needs a deliberate adoption path. ArchUnit's freezing facility can store current violations as a baseline so that new violations fail while known debt is addressed separately. Freeze files should be reviewed like snapshots, with every newly ignored issue justified. Custom ArchCondition and DescribedPredicate implementations can support precise project rules, but bespoke logic also deserves its own tests.
Comparing checks within delivery workflows
Delivery context matters. Teams distributed across Sydney, Melbourne, Brisbane and Perth often work in hybrid arrangements, so CI provides a common, time-zone-independent definition of acceptable change. Australian teams may call a late-afternoon check-in an arvo sync, but build results should not depend on who is online; they should be reproducible and stored with the commit. Failure messages should also use familiar Australian English, such as behaviour and organisation.
Many Australian teams use Atlassian's Bitbucket Pipelines and Jira, products built by the Sydney-founded company, although ArchUnit runs equally well with GitHub Actions, GitLab CI or Jenkins. For organisations handling personal information covered by the Australian Privacy Act and the Australian Privacy Principles, explicit package boundaries can reduce accidental sharing of customer-data access code. That supports local governance expectations without turning every architectural test into a compliance claim.
Run architecture tests in a dedicated pipeline stage or as part of the normal verification job, publish the violation report as a build artefact, and prevent merges when a rule fails. The checks should use the same Java version and dependency lock as the application build; otherwise an architecture result may differ from the artefact being released. Several enforcement styles cover different needs:
| Approach | What it catches | Strongest use | Main limitation |
|---|---|---|---|
| Direct package rule | A named package depends on forbidden types | Clear module boundaries | Package names can drift |
| Layered architecture | Dependencies point in an unexpected direction | Layered applications | Layers must reflect the actual design |
| Slice rule | Cycles between feature packages | Feature-oriented systems | Requires consistent namespaces |
| Frozen baseline | New violations beyond accepted legacy debt | Incremental adoption | Existing debt remains |
| Custom condition | Project-specific structural problems | Repeated bespoke constraints | More code and maintenance |
These options assume rules are automated. A manual review board can still adjudicate ambiguous exceptions, but it should not be the only control. Combining static checks with pull-request review gives developers fast local feedback while retaining human judgement for trade-offs.
A practical architecture guardrail checklist
A useful rollout balances immediate value, maintainability and team ownership. The following checklist keeps the first version of an architecture-test suite practical:
- Select one boundary tied to a real risk, such as domain code remaining independent of web frameworks or controllers avoiding repositories.
- Express allowed dependency direction explicitly, naming the packages and layers that each side may use.
- Apply slice cycle detection only where package naming is consistent; otherwise use direct rules that explain failures clearly.
- Run architecture tests separately during development, then include them in the mandatory CI verification path before merge.
- Freeze legacy violations deliberately, review every exception and remove baseline entries as affected packages are refactored.
- Assign ownership for rule changes and require peer review, since permissive exceptions can quietly dissolve a boundary.
Begin with the checks that prevent the most costly drift, not with every idea in the architecture documentation. Each rule should have an owner, a short rationale and a clear failure message. If a rule produces noisy or irrelevant failures, refine or remove it rather than training the team to ignore red builds.
The practical takeaway is simple: choose one agreed boundary, encode it as a fast test and keep that promise green on every change.