Add archunit module: ArchUnit for Spring Boot - enforcing layered architecture as a unit test
This commit is contained in:
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
Method <com.ankurm.tutorials.junit.archunit.service.LegacyOrderExporter.export()> does declare throwable of type java.lang.Exception in (LegacyOrderExporter.java:14)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.ankurTutorials</groupId>
|
||||
<artifactId>archunit-demo</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.release>17</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<junit.version>6.1.3</junit.version>
|
||||
<archunit.version>1.5.1</archunit.version>
|
||||
<spring.version>7.0.9</spring.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- Just enough Spring to annotate the sample app with real stereotypes
|
||||
(@RestController, @Service, @Repository, @Autowired), not spring-boot-starter,
|
||||
no embedded server, no ApplicationContext anywhere in this module. ArchUnit reads
|
||||
compiled classes; it has never needed a running Spring context to do it. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
<version>${spring.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-webmvc</artifactId>
|
||||
<version>${spring.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>${archunit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.2</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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<String, String> statusesByOrderId = new ConcurrentHashMap<>(
|
||||
Map.of("o-1", "SHIPPED", "o-2", "PLACED"));
|
||||
|
||||
@Override
|
||||
public Optional<String> findStatus(String orderId) {
|
||||
return Optional.ofNullable(statusesByOrderId.get(orderId));
|
||||
}
|
||||
}
|
||||
+8
@@ -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<String> findStatus(String orderId);
|
||||
}
|
||||
+15
@@ -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.
|
||||
}
|
||||
}
|
||||
+24
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>{@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"));
|
||||
}
|
||||
+42
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user