Making architecture hold up over time
You drew the boundaries. A domain that depends on nothing, ports as interfaces, adapters all around. That is the subject of hexagonal architecture in Spring Boot.
Six months later, the domain imports the framework.
Nobody made that decision. It happened on a Friday evening, in an urgent fix, with an annotation added “just this once”. The review went straight past it. The code works. Nobody noticed a thing. But the rule is gone.
This article is about the one thing that really prevents that: writing the rule into a test, so the machine checks it on every build. First why, then how, with ArchUnit on the Java side and Konsist on the Kotlin side.
Every example below was actually run, and the outputs shown are the real ones, on ArchUnit 1.4.2 and Konsist 0.17.3.
A rule nobody checks is not a rule
An architecture rule usually lives in three places. A diagram in a wiki. A paragraph in the README. And the heads of the people who were there when the decision was made.
None of the three blocks a commit.
That leaves code review. It works, on three conditions: the reviewer knows the rule, spots it going past in the diff, and has the time. Those three conditions fall apart fast. Someone new on the team, a rushed fix, a 400-line diff on a Thursday evening.
And erosion never looks like one big breach. It is twenty small slips, and each one looks reasonable on its own. A year later nobody knows where the boundary runs, because there is no boundary left.
The compiler, on the other hand, lets nothing through. A type that does not match, a private you try to read from outside: it does not compile, and there is nothing to discuss. An architecture rule is simply a rule the compiler does not know about yet. The whole job is teaching it. And where that is not possible, you teach it to a test.
What an architecture test can do
Four families of rules can be checked mechanically, and they cover most of what matters:
- the direction of dependencies between packages or modules, the most useful rule of all;
- what a layer is allowed to use, for example “the domain imports neither the web framework nor persistence”;
- placement and naming conventions, for example “anything ending in
UseCaselives in theapplicationpackage”; - the absence of cycles between packages.
What no tool can tell you is whether the boundary is in the right place. A test tells you the rule is respected. It will never tell you the rule is good. A bad boundary that is automatically enforced is still a bad boundary. And the day you want to move it, the test is one more obstacle.
One practical detail matters a lot: these tests are tests. They run with the others, they break the build like the others, they show up in the same report. No extra pipeline, no tool for everyone to install. That is exactly why they survive.
Split first, test second
The strongest rule is the one that cannot be broken.
If the domain is its own build module, and that module does not have the framework among its dependencies, then importing the framework into the domain does not compile. There is no rule to write, no tool to maintain, and the error shows up in the IDE before the commit.
Do this wherever you can. But it does not cover everything. Splitting into modules has a cost, and above all, inside a module the compiler does not care about your packages: domain and adapter side by side are the same thing to it. That is exactly where the test takes over.
ArchUnit, on a Java project
ArchUnit is a test library. It reads your compiled classes and lets you write rules over them, in an API that reads almost like an English sentence.
A single test dependency:
testImplementation("com.tngtech.archunit:archunit-junit5:1.4.2")
Take the layout from the previous article: domain, application, adapter. The first rule is the one that matters most, and it is the shortest to write.
@AnalyzeClasses(packages = "com.example.account",
importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
@ArchTest
static final ArchRule the_domain_ignores_the_framework = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "jakarta.persistence..");
}
Now put a @Component on a domain class, the way that famous Friday evening fix would. The test falls over:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'no classes
that reside in a package '..domain..' should depend on classes that reside in any
package ['org.springframework..', 'jakarta.persistence..']' was violated (1 times):
Class <com.example.account.domain.Account> is annotated with
<org.springframework.stereotype.Component> in (Account.java:0)
The message has everything you need: the rule spelled out, how many violations, the offending class and the annotation behind it.
One detail worth knowing so it does not worry you: that Account.java:0. It is not a bug. ArchUnit works on bytecode, and an annotation does not keep its line number there. For a method call the line is exact, as you are about to see.
The layer rule
The rule above forbids one specific dependency. This one describes the direction of travel between layers, which is closer to what you draw on a whiteboard:
@ArchTest
static final ArchRule the_layers = layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Adapter").definedBy("..adapter..")
.whereLayer("Adapter").mayNotBeAccessedByAnyLayer()
.whereLayer("Application").mayOnlyBeAccessedByLayers("Adapter")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Adapter");
Call an adapter class from the domain, and you get:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'Layered
architecture considering all dependencies, consisting of
layer 'Domain' ('..domain..')
layer 'Application' ('..application..')
layer 'Adapter' ('..adapter..')
where layer 'Adapter' may not be accessed by any layer
where layer 'Application' may only be accessed by layers ['Adapter']
where layer 'Domain' may only be accessed by layers ['Application', 'Adapter']'
was violated (1 times):
Method <com.example.account.domain.Account.display()> calls method
<com.example.account.adapter.web.AccountFormatter.format(java.lang.String)>
in (Account.java:9)
This time the line is there, Account.java:9. You read the offending method, what it calls, and where. Fixing it takes no investigation.
Two words about the annotations on the test class. @AnalyzeClasses declares the package to analyse. The classes are read only once: ArchUnit caches them, and another test class asking for the same package reuses that same read. ImportOption.DoNotIncludeTests leaves your own tests out of the analysis, which is almost always what you want: a test is allowed to know about everything.
The case of existing code
On a project that has been running for three years, the first rule you write will report two hundred violations. Nobody is fixing those this week. And a rule that stays red permanently always ends the same way: commented out.
FreezingArchRule exists for exactly this moment. It records the existing violations in a file, versioned along with the code, and only fails the build on new ones.
@ArchTest
static final ArchRule the_layers = FreezingArchRule.freeze(
layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
// ... the rest of the rule, unchanged
);
With, in src/test/resources/archunit.properties, permission to create the file on the first run:
freeze.store.default.allowStoreCreation=true
The behaviour is exactly what you would hope for. On the first run the violations already in place are recorded under archunit_store/ and the build passes. Add a new one, and only that one is reported:
Method <com.example.account.application.Extra.go()> calls method
<com.example.account.adapter.web.AccountFormatter.format(java.lang.String)>
in (Extra.java:4)
And it works both ways. Fix a frozen violation and it disappears from the file. Reintroduce it afterwards and it is no longer accepted, so the build breaks. The number of violations can only go down, without ever blocking the team on the day the rule arrives.
Konsist, on a Kotlin project
On a project written entirely in Kotlin, Konsist does the same job. It reads your source files and exposes what it finds there, classes, functions, imports, packages, through an API you chain like a collection.
Again, one test dependency:
testImplementation("com.lemonappdev:konsist:0.17.3")
Let us take the same two rules. The first one, the domain that ignores the framework, is expressed on the imports of the domain files:
@Test
fun `the domain ignores the framework`() {
Konsist
.scopeFromProduction()
.files
.filter { it.packagee?.name?.startsWith("com.example.account.domain") == true }
.assertFalse { it.hasImport { import -> import.name.startsWith("org.springframework") } }
}
scopeFromProduction() takes the production code and leaves the tests out. The rest reads by itself: among those files, the domain ones, and none of them may have a Spring import.
Put the offending annotation back, and Konsist names the file:
com.lemonappdev.konsist.core.exception.KoAssertionFailedException:
Assert 'the domain ignores the framework' was violated (1 time). Invalid files:
└── File Account.kt file:///.../src/main/kotlin/com/example/account/domain/Account.kt
The layer rule has an API of its own, with named layers and declared dependencies:
@Test
fun `the layers respect the direction of dependencies`() {
Konsist
.scopeFromProduction()
.assertArchitecture {
val domain = Layer("Domain", "com.example.account.domain..")
val application = Layer("Application", "com.example.account.application..")
val adapter = Layer("Adapter", "com.example.account.adapter..")
domain.dependsOnNothing()
application.dependsOn(domain)
adapter.dependsOn(application, domain)
}
}
The same slip as before, the domain calling the adapter, gives:
com.lemonappdev.konsist.core.exception.KoAssertionFailedException:
'the layers respect the direction of dependencies' test has failed.
'Domain' layer should not depend on anything but has dependencies in files:
└── File file:///.../src/main/kotlin/com/example/account/domain/Account.kt
└── Import com.example.account.adapter.web.AccountFormatter (file:///.../Account.kt:3:1)
The file, the offending import, the line and the column.
Two things to know before running this on your own project.
First, Konsist looks for the project root by walking up the directories, and it needs a .git to find it. Without one, the test fails on a Project directory not found that has nothing to do with your rules. In a real repository the question never comes up.
Second, the layer rule reads the imports. A dependency written as a fully qualified name, with no import, therefore goes straight through:
class Account(val id: String) {
fun display() = com.example.account.adapter.web.AccountFormatter.format(id)
}
This code really does call the adapter from the domain, and the test stays green. In practice, nobody writes Kotlin like that. But it is worth knowing, so you do not believe the rule is wider than it is.
Where to start
Do not write fifteen rules on day one. Write one, the one the team has already brought up twice in review. One useful rule that runs beats a complete set nobody follows.
Then check that the failure message says what to do. Both tools name the offending file, which is most of the battle. Give your tests names that explain the intent rather than the mechanics: someone will read them one morning with no context, a red build and a release to ship.
On existing code, freeze. A permanently red rule protects nothing, it just teaches the team to ignore one more red line.
And only put under test the rules the team agrees on. An architecture test is not a way to win a design argument. It is a way to avoid having that argument again every three months.
In short
An architecture rule that only exists in a diagram does not survive six months of sprints. Code review is not enough, because it depends on someone’s memory and someone’s availability.
Where you can, let the module layout carry the rule: what does not compile is not up for discussion. For everything else, a test.
That is what we just saw with ArchUnit on a Java project and Konsist on a Kotlin one: they enforce the rule on every build. That is what keeps your boundaries standing over time.
On existing code, freeze the violations already in place and only fail the build on new ones. Their number can then only go down.
And remember what a test will never do: tell you the boundary is in the right place. That part stays your job.