From 8be12459517d1b648812493621e1158768af2f61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 21:21:03 +0000 Subject: [PATCH] Add archunit module: ArchUnit for Spring Boot - enforcing layered architecture as a unit test --- archunit/README.md | 73 +++++++++++++++++++ .../9e504a56-cf50-43d0-a977-82f8a097091b | 1 + archunit/archunit_store/stored.rules | 3 + .../docs/output/00-baseline-passing-run.txt | 17 +++++ .../output/01-layering-violation-failure.txt | 46 ++++++++++++ .../02-freeze-run1-baseline-captured.txt | 33 +++++++++ .../03-freeze-run2-new-violation-caught.txt | 32 ++++++++ archunit/docs/output/04-full-suite-final.txt | 17 +++++ archunit/pom.xml | 69 ++++++++++++++++++ .../controller/OrderStatusController.java | 22 ++++++ .../repository/InMemoryOrderRepository.java | 19 +++++ .../archunit/repository/OrderRepository.java | 8 ++ .../archunit/service/LegacyOrderExporter.java | 15 ++++ .../archunit/service/OrderStatusService.java | 24 ++++++ .../junit/archunit/FreezingArchRuleTest.java | 32 ++++++++ .../archunit/LayeredArchitectureTest.java | 42 +++++++++++ .../src/test/resources/archunit.properties | 6 ++ 17 files changed, 459 insertions(+) create mode 100644 archunit/README.md create mode 100644 archunit/archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b create mode 100644 archunit/archunit_store/stored.rules create mode 100644 archunit/docs/output/00-baseline-passing-run.txt create mode 100644 archunit/docs/output/01-layering-violation-failure.txt create mode 100644 archunit/docs/output/02-freeze-run1-baseline-captured.txt create mode 100644 archunit/docs/output/03-freeze-run2-new-violation-caught.txt create mode 100644 archunit/docs/output/04-full-suite-final.txt create mode 100644 archunit/pom.xml create mode 100644 archunit/src/main/java/com/ankurm/tutorials/junit/archunit/controller/OrderStatusController.java create mode 100644 archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/InMemoryOrderRepository.java create mode 100644 archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/OrderRepository.java create mode 100644 archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java create mode 100644 archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/OrderStatusService.java create mode 100644 archunit/src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java create mode 100644 archunit/src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java create mode 100644 archunit/src/test/resources/archunit.properties diff --git a/archunit/README.md b/archunit/README.md new file mode 100644 index 0000000..cbdffc1 --- /dev/null +++ b/archunit/README.md @@ -0,0 +1,73 @@ +# ArchUnit for Spring Boot — Enforcing Layered Architecture as a Unit Test + +Companion module for [ArchUnit for Spring Boot: Enforcing Layered Architecture as a Unit +Test](https://ankurm.com/archunit-spring-boot-layered-architecture-unit-test/) on +[ankurm.com](https://ankurm.com). Every code sample and every console transcript quoted in +that post comes from the files in this directory — nothing was hand-typed into the article. + +## Versions this was built and run against + +| Component | Version | Notes | +|---|---|---| +| ArchUnit | **1.5.1** | current GA per `maven-metadata.xml` on Maven Central at the time of writing | +| JUnit Jupiter / Platform | **6.1.3** | via `archunit-junit5`, which brings its own JUnit 5 extension | +| Spring Framework | 7.0.9 | `spring-context` + `spring-webmvc` only — no `spring-boot-starter-web`, no embedded server, no `ApplicationContext` anywhere in this module | +| JDK | **25 (Temurin, LTS)** | build and run; `maven.compiler.release` is set to 17 | +| Maven | 3.9.11 | | +| Maven Surefire Plugin | 3.5.2 | | + +This module deliberately does not depend on any `spring-boot-starter-*` artifact. ArchUnit +reads compiled `.class` files directly — it has never needed a running Spring context, and +the whole point of the first section of the post is that none of these tests say +`@SpringBootTest`. + +## Quickstart + +```bash +mvn test # all four rules, no application ever starts, ~4s total +mvn test -Dtest=LayeredArchitectureTest # just the three structural rules +mvn test -Dtest=FreezingArchRuleTest # just the frozen-violation rule +``` + +Gradle users: there is no `build.gradle.kts` in this module (unlike this repo's other +modules) because the whole demonstration is Maven-plugin-agnostic — `archunit-junit5` +plugs into the JUnit 5 engine the same way under either build tool, and the commands above +are the part that matters. + +## Source files + +| File | Demonstrates | +|---|---| +| [`OrderRepository.java`](src/main/java/com/ankurm/tutorials/junit/archunit/repository/OrderRepository.java) / [`InMemoryOrderRepository.java`](src/main/java/com/ankurm/tutorials/junit/archunit/repository/InMemoryOrderRepository.java) | the repository layer — a `@Repository` with no framework beyond the stereotype annotation itself | +| [`OrderStatusService.java`](src/main/java/com/ankurm/tutorials/junit/archunit/service/OrderStatusService.java) | the service layer — constructor injection only, which is what makes `noFieldInjection` meaningful rather than cosmetic | +| [`OrderStatusController.java`](src/main/java/com/ankurm/tutorials/junit/archunit/controller/OrderStatusController.java) | the web layer — depends on the service layer only | +| [`LegacyOrderExporter.java`](src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java) | a deliberately-kept pre-existing `throws Exception` violation — the thing `FreezingArchRuleTest` freezes rather than fixes | +| [`LayeredArchitectureTest.java`](src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java) | `layeredArchitecture()`, `noFields().should().beAnnotatedWith(Autowired.class)`, and `slices().should().beFreeOfCycles()` — three independent structural rules, all checked without starting anything | +| [`FreezingArchRuleTest.java`](src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java) | `FreezingArchRule.freeze(...)` wrapping a rule that would otherwise fail immediately, recording today's known violations as an accepted baseline | +| [`archunit.properties`](src/test/resources/archunit.properties) | the three properties that control where the frozen-violation store lives and whether it may be created or updated | +| [`archunit_store/`](archunit_store) | the frozen-violation store itself, committed to version control — this directory **is** the baseline the rule enforces against | + +## Captured output + +| File | What it shows | +|---|---| +| [`00-baseline-passing-run.txt`](docs/output/00-baseline-passing-run.txt) | the three structural rules, all green, ~1.3s, no `ApplicationContext` started | +| [`01-layering-violation-failure.txt`](docs/output/01-layering-violation-failure.txt) | a real `layeredArchitecture()` failure, captured by temporarily adding a controller that bypassed the service layer, then deleting it | +| [`02-freeze-run1-baseline-captured.txt`](docs/output/02-freeze-run1-baseline-captured.txt) | the first-ever run of `FreezingArchRuleTest`: no store existed yet, so it was created with today's one known violation already accepted | +| [`03-freeze-run2-new-violation-caught.txt`](docs/output/03-freeze-run2-new-violation-caught.txt) | a second, genuinely new `throws Exception` added after the baseline was recorded — the failure names only the new offender, never the frozen one | +| [`04-full-suite-final.txt`](docs/output/04-full-suite-final.txt) | all four rules, exactly as committed, green in under four seconds | + +## A diagnostic note, not a defect + +`LegacyOrderExporter.export()` still declares `throws Exception` in the committed code, and +`noNewGenericExceptionsDeclared` in `FreezingArchRuleTest` still technically covers it. The +build is green anyway, on purpose: `archunit_store/` records that specific violation as +already known at the time the rule was turned on, and `freeze()` only fails a build on a +violation that is *new* since that recording. This is the realistic case — a team adopting +ArchUnit on a codebase that is not already clean — and the point the post's freezing section +makes is that you do not have to choose between "fix two hundred existing violations today" +and "don't bother enforcing the rule at all." + +## License + +MIT, matching the rest of this repository. diff --git a/archunit/archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b b/archunit/archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b new file mode 100644 index 0000000..e1d1aa2 --- /dev/null +++ b/archunit/archunit_store/9e504a56-cf50-43d0-a977-82f8a097091b @@ -0,0 +1 @@ +Method does declare throwable of type java.lang.Exception in (LegacyOrderExporter.java:14) diff --git a/archunit/archunit_store/stored.rules b/archunit/archunit_store/stored.rules new file mode 100644 index 0000000..ecd0dca --- /dev/null +++ b/archunit/archunit_store/stored.rules @@ -0,0 +1,3 @@ +# +#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 diff --git a/archunit/docs/output/00-baseline-passing-run.txt b/archunit/docs/output/00-baseline-passing-run.txt new file mode 100644 index 0000000..176dd14 --- /dev/null +++ b/archunit/docs/output/00-baseline-passing-run.txt @@ -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. diff --git a/archunit/docs/output/01-layering-violation-failure.txt b/archunit/docs/output/01-layering-violation-failure.txt new file mode 100644 index 0000000..6e3edf8 --- /dev/null +++ b/archunit/docs/output/01-layering-violation-failure.txt @@ -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.repository.OrderRepository)> has parameter of type in (BadOrderLookupController.java:0) +Field has type in (BadOrderLookupController.java:0) +Method calls method 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. diff --git a/archunit/docs/output/02-freeze-run1-baseline-captured.txt b/archunit/docs/output/02-freeze-run1-baseline-captured.txt new file mode 100644 index 0000000..2cf257b --- /dev/null +++ b/archunit/docs/output/02-freeze-run1-baseline-captured.txt @@ -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 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. diff --git a/archunit/docs/output/03-freeze-run2-new-violation-caught.txt b/archunit/docs/output/03-freeze-run2-new-violation-caught.txt new file mode 100644 index 0000000..cc90c58 --- /dev/null +++ b/archunit/docs/output/03-freeze-run2-new-violation-caught.txt @@ -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 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. diff --git a/archunit/docs/output/04-full-suite-final.txt b/archunit/docs/output/04-full-suite-final.txt new file mode 100644 index 0000000..9eb061c --- /dev/null +++ b/archunit/docs/output/04-full-suite-final.txt @@ -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. diff --git a/archunit/pom.xml b/archunit/pom.xml new file mode 100644 index 0000000..5474490 --- /dev/null +++ b/archunit/pom.xml @@ -0,0 +1,69 @@ + + + 4.0.0 + + com.ankurTutorials + archunit-demo + 1.0-SNAPSHOT + + + 17 + UTF-8 + 6.1.3 + 1.5.1 + 7.0.9 + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + + org.springframework + spring-context + ${spring.version} + + + org.springframework + spring-webmvc + ${spring.version} + + + + org.junit.jupiter + junit-jupiter + test + + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + + diff --git a/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/controller/OrderStatusController.java b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/controller/OrderStatusController.java new file mode 100644 index 0000000..f3e63bd --- /dev/null +++ b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/controller/OrderStatusController.java @@ -0,0 +1,22 @@ +package com.ankurm.tutorials.junit.archunit.controller; + +import com.ankurm.tutorials.junit.archunit.service.OrderStatusService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** The web layer. Depends on the service layer only -- never on {@code repository} directly. */ +@RestController +public class OrderStatusController { + + private final OrderStatusService orderStatusService; + + public OrderStatusController(OrderStatusService orderStatusService) { + this.orderStatusService = orderStatusService; + } + + @GetMapping("/orders/{orderId}/status") + public String status(@PathVariable String orderId) { + return orderStatusService.statusOf(orderId); + } +} diff --git a/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/InMemoryOrderRepository.java b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/InMemoryOrderRepository.java new file mode 100644 index 0000000..f1343c1 --- /dev/null +++ b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/InMemoryOrderRepository.java @@ -0,0 +1,19 @@ +package com.ankurm.tutorials.junit.archunit.repository; + +import org.springframework.stereotype.Repository; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Repository +public class InMemoryOrderRepository implements OrderRepository { + + private final Map statusesByOrderId = new ConcurrentHashMap<>( + Map.of("o-1", "SHIPPED", "o-2", "PLACED")); + + @Override + public Optional findStatus(String orderId) { + return Optional.ofNullable(statusesByOrderId.get(orderId)); + } +} diff --git a/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/OrderRepository.java b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/OrderRepository.java new file mode 100644 index 0000000..a5796d4 --- /dev/null +++ b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/repository/OrderRepository.java @@ -0,0 +1,8 @@ +package com.ankurm.tutorials.junit.archunit.repository; + +import java.util.Optional; + +/** The persistence boundary. Only a class in this package, or {@code service}, should ever call this. */ +public interface OrderRepository { + Optional findStatus(String orderId); +} diff --git a/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java new file mode 100644 index 0000000..efba4c7 --- /dev/null +++ b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/LegacyOrderExporter.java @@ -0,0 +1,15 @@ +package com.ankurm.tutorials.junit.archunit.service; + +/** + * Stands in for code that existed before anyone wired ArchUnit into this module: a method + * that declares {@code throws Exception} instead of something specific. Nobody is fixing this + * today, and a rule banning it outright would fail the build over a problem nobody signed up + * to solve right now. {@code FreezingArchRuleTest} freezes this one known violation so the + * rule can still be turned on -- see the module README for what "freezing" means here. + */ +public class LegacyOrderExporter { + + public void export() throws Exception { + // Deliberately vague: this is the thing a real legacy codebase actually looks like. + } +} diff --git a/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/OrderStatusService.java b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/OrderStatusService.java new file mode 100644 index 0000000..ace19b7 --- /dev/null +++ b/archunit/src/main/java/com/ankurm/tutorials/junit/archunit/service/OrderStatusService.java @@ -0,0 +1,24 @@ +package com.ankurm.tutorials.junit.archunit.service; + +import com.ankurm.tutorials.junit.archunit.repository.OrderRepository; +import org.springframework.stereotype.Service; + +/** + * The service layer. Constructor injection only -- no {@code @Autowired} field anywhere in + * this class. That's not a style preference being enforced here for its own sake; it's what + * makes {@code NoFieldInjectionTest} able to tell the difference between "this class declares + * its dependency" and "this class has one injected into it by magic after construction." + */ +@Service +public class OrderStatusService { + + private final OrderRepository orderRepository; + + public OrderStatusService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + public String statusOf(String orderId) { + return orderRepository.findStatus(orderId).orElse("UNKNOWN"); + } +} diff --git a/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java b/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java new file mode 100644 index 0000000..c19eb60 --- /dev/null +++ b/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/FreezingArchRuleTest.java @@ -0,0 +1,32 @@ +package com.ankurm.tutorials.junit.archunit; + +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.noMethods; +import static com.tngtech.archunit.library.freeze.FreezingArchRule.freeze; + +/** + * {@code noMethods().should().declareThrowableOfType(Exception.class)} is a rule most + * codebases fail the instant they turn it on, because {@code throws Exception} is scattered + * through whatever existed before anyone cared. Wrapping the rule in {@code freeze(...)} + * records today's violations as the accepted baseline (see {@code archunit_store/} next to + * this module's pom.xml -- that directory is committed, and it IS the baseline) and only + * fails the build on a violation that is NEW since that baseline was recorded. + * + *

{@code LegacyOrderExporter.export()} is the one violation this project already had when + * this rule was written. It is frozen. It will keep passing. Any new method anyone adds later + * that also declares {@code throws Exception} will fail this test on its own, without the + * frozen one ever needing to be fixed first. See {@code docs/output/02-*.txt} and + * {@code docs/output/03-*.txt} for two real runs proving exactly that. + */ +@AnalyzeClasses(packages = "com.ankurm.tutorials.junit.archunit") +class FreezingArchRuleTest { + + @ArchTest + static final ArchRule noNewGenericExceptionsDeclared = freeze( + noMethods().should().declareThrowableOfType(Exception.class) + .because("a specific exception type documents what a caller actually " + + "has to handle; `throws Exception` documents nothing")); +} diff --git a/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java b/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java new file mode 100644 index 0000000..e2d7d4b --- /dev/null +++ b/archunit/src/test/java/com/ankurm/tutorials/junit/archunit/LayeredArchitectureTest.java @@ -0,0 +1,42 @@ +package com.ankurm.tutorials.junit.archunit; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.beans.factory.annotation.Autowired; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; + +/** + * No Spring context anywhere in this class. {@code @AnalyzeClasses} points ArchUnit at the + * compiled {@code .class} files under this package and these four rules run against them + * directly -- the whole suite finishes in well under a second. + */ +@AnalyzeClasses(packages = "com.ankurm.tutorials.junit.archunit") +class LayeredArchitectureTest { + + @ArchTest + static final ArchRule controllersOnlyCallServicesWhichOnlyCallRepositories = layeredArchitecture() + .consideringAllDependencies() + .layer("Controller").definedBy("..controller..") + .layer("Service").definedBy("..service..") + .layer("Repository").definedBy("..repository..") + .whereLayer("Controller").mayNotBeAccessedByAnyLayer() + .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") + .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service"); + + @ArchTest + static final ArchRule noFieldInjection = noFields() + .that().areDeclaredInClassesThat().resideInAPackage("com.ankurm.tutorials.junit.archunit..") + .should().beAnnotatedWith(Autowired.class) + .because("constructor injection is what lets a test construct these classes with " + + "plain `new`, and what makes a missing dependency a compile error instead " + + "of a null field discovered at 2am"); + + @ArchTest + static final ArchRule packagesAreFreeOfCycles = slices() + .matching("com.ankurm.tutorials.junit.archunit.(*)..") + .should().beFreeOfCycles(); +} diff --git a/archunit/src/test/resources/archunit.properties b/archunit/src/test/resources/archunit.properties new file mode 100644 index 0000000..05b6435 --- /dev/null +++ b/archunit/src/test/resources/archunit.properties @@ -0,0 +1,6 @@ +# Where FreezingArchRule's default TextFileBasedViolationStore keeps its frozen-violation +# records, and whether it's allowed to create/update that store. Commit the store directory +# itself to version control -- it IS the baseline this rule is enforcing against. +freeze.store.default.path=archunit_store +freeze.store.default.allowStoreCreation=false +freeze.store.default.allowStoreUpdate=true