Add archunit module: ArchUnit for Spring Boot - enforcing layered architecture as a unit test
This commit is contained in:
+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