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,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);
}
}
@@ -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));
}
}
@@ -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);
}
@@ -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.
}
}
@@ -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"));
}
@@ -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