Add archunit module: ArchUnit for Spring Boot - enforcing layered architecture as a unit test

This commit is contained in:
Claude
2026-10-03 21:21:03 +00:00
parent 3d05e483cc
commit 8be1245951
17 changed files with 459 additions and 0 deletions
@@ -0,0 +1,17 @@
Captured from a real `mvn test` run against the module as committed: three
ArchUnit rules (layered architecture, no field injection, no package cycles),
all passing, no Spring context started anywhere in the run.
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.311 s -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Results:
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 3.618 s
The whole suite -- three architecture rules checked across every compiled class
in the module -- finishes in about 1.3 seconds. There is no "Started
Application in N seconds" line anywhere in this output, because there is no
Application to start: @AnalyzeClasses reads .class files from disk, the same
way a decompiler would, and none of these three rules need a single bean to
exist at runtime to be checked.
@@ -0,0 +1,46 @@
Captured from a real `mvn test` run with a temporary class added to the controller
package, com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController,
whose constructor took OrderRepository directly instead of going through
OrderStatusService. The class was deleted immediately after this run; it exists
only in this transcript and in the "before/after" diagram in the published post.
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.445 s <<< FAILURE! -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[ERROR] LayeredArchitectureTest.controllersOnlyCallServicesWhichOnlyCallRepositories -- Time elapsed: 1.402 s <<< FAILURE!
java.lang.AssertionError:
Architecture Violation [Priority: MEDIUM] - Rule 'Layered architecture considering all dependencies, consisting of
layer 'Controller' ('..controller..')
layer 'Service' ('..service..')
layer 'Repository' ('..repository..')
where layer 'Controller' may not be accessed by any layer
where layer 'Service' may only be accessed by layers ['Controller']
where layer 'Repository' may only be accessed by layers ['Service']' was violated (3 times):
Constructor <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.<init>(com.ankurm.tutorials.junit.archunit.repository.OrderRepository)> has parameter of type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
Field <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.orderRepository> has type <com.ankurm.tutorials.junit.archunit.repository.OrderRepository> in (BadOrderLookupController.java:0)
Method <com.ankurm.tutorials.junit.archunit.controller.BadOrderLookupController.rawStatus(java.lang.String)> calls method <com.ankurm.tutorials.junit.archunit.repository.OrderRepository.findStatus(java.lang.String)> in (BadOrderLookupController.java:25)
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
at com.tngtech.archunit.library.Architectures$LayeredArchitecture.check(Architectures.java:347)
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:168)
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:151)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
[INFO] Results:
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
Three things worth noticing in this message:
1. ArchUnit reports all THREE offending facts about the one bad class in a single
violation group: the constructor parameter, the field it gets assigned to, and
the actual method call that crosses the layer boundary. One bad wire gets
reported three ways because ArchUnit checks dependency *existence*, not just
call sites -- the field and the constructor parameter are both "access" on
their own, even on a line that never executes.
2. The file:line at the end of each fact (BadOrderLookupController.java:25) points
at real source positions ArchUnit extracted from the compiled .class file's
debug info, not from parsing the .java file.
3. "Priority: MEDIUM" is the rule's declared priority, not a severity the test
assigns at failure time -- layeredArchitecture() rules default to MEDIUM, and
a build can be configured to fail the whole run only above a given priority.
@@ -0,0 +1,33 @@
Captured from a real `mvn test -Dtest=FreezingArchRuleTest` run with no archunit_store/
directory present yet and freeze.store.default.allowStoreCreation=true in
src/test/resources/archunit.properties.
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.268 s -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] Results:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
The build is green even though LegacyOrderExporter.export() genuinely declares
`throws Exception` -- the one thing noMethods().should().declareThrowableOfType(Exception.class)
exists to ban. That's the point of freeze(): on this first run there was no stored
baseline yet, so FreezingArchRule recorded every violation it found right now as
"already known" and created the store on disk instead of failing. The store this
run produced, committed alongside this module:
archunit_store/stored.rules:
#
#Sun Oct 04 02:49:04 IST 2026
no\ methods\ should\ declare\ throwable\ of\ type\ java.lang.Exception,\ because\ a\ specific\
exception\ type\ documents\ what\ a\ caller\ actually\ has\ to\ handle;\ `throws\ Exception`\
documents\ nothing=9e504a56-cf50-43d0-a977-82f8a097091b
archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b:
Method <com.ankurm.tutorials.junit.archunit.service.LegacyOrderExporter.export()> does
declare throwable of type java.lang.Exception in (LegacyOrderExporter.java:14)
stored.rules maps one line per frozen rule (keyed by the rule's full description) to a
UUID; that UUID is also a filename holding the exact violation text frozen under it, in
the same format the live failure message uses. Delete that second file and the rule
forgets the violation was ever acceptable -- which is exactly how you make a frozen
violation start failing again once someone finally fixes it.
@@ -0,0 +1,32 @@
Captured from a real `mvn test -Dtest=FreezingArchRuleTest` run, taken immediately after
the first run (docs/output/02-freeze-run1-baseline-captured.txt) had already frozen
LegacyOrderExporter.export() into archunit_store/. Between the two runs: a second class,
NewOrderBatchJob, was added with a method making the exact same mistake
(`run() throws Exception`), and freeze.store.default.allowStoreCreation was flipped to
false in archunit.properties -- the normal CI setting once a baseline exists, so nobody
can accidentally create a fresh, empty baseline that silently un-freezes everything.
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 1.156 s <<< FAILURE! -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[ERROR] FreezingArchRuleTest.noNewGenericExceptionsDeclared -- Time elapsed: 1.145 s <<< FAILURE!
java.lang.AssertionError:
Architecture Violation [Priority: MEDIUM] - Rule 'no methods should declare throwable of type java.lang.Exception, because a specific exception type documents what a caller actually has to handle; `throws Exception` documents nothing' was violated (1 times):
Method <com.ankurm.tutorials.junit.archunit.service.NewOrderBatchJob.run()> does declare throwable of type java.lang.Exception in (NewOrderBatchJob.java:15)
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
at com.tngtech.archunit.library.freeze.FreezingArchRule.check(FreezingArchRule.java:97)
[INFO] Results:
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE
This is the entire point of freeze(): the violation this test reports is ONLY
NewOrderBatchJob.run(). LegacyOrderExporter.export() still declares `throws Exception`
too -- nothing about it changed -- and it is not mentioned anywhere in this failure,
because it was already recorded as accepted in archunit_store/ before this run started.
A rule that would otherwise have to fail against every pre-existing offender at once
instead fails against exactly one thing: the offender that showed up after the team
agreed to stop adding new ones.
NewOrderBatchJob was deleted immediately after this run; it was never meant to survive
past this transcript. docs/output/00- and docs/output/02- show the suite back to green
with it removed and the frozen baseline still in place.
@@ -0,0 +1,17 @@
Captured from a real `mvn test` run against the module exactly as committed: all four
rules across both test classes, including the frozen rule with its one accepted legacy
violation still on record in archunit_store/.
[INFO] Running com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.323 s -- in com.ankurm.tutorials.junit.archunit.LayeredArchitectureTest
[INFO] Running com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.051 s -- in com.ankurm.tutorials.junit.archunit.FreezingArchRuleTest
[INFO] Results:
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 3.847 s
Four rules, four green results, under four seconds total, with zero application
classes started. This is the number to compare against docs/output/01- and
docs/output/03- -- both of those are the same suite with one extra class added on
purpose, each producing exactly one new failure and nothing else.