test(backend): add ArchUnit domain boundary enforcement (Rules 1–4) #428
@@ -108,6 +108,12 @@
|
||||
<groupId>org.awaitility</groupId>
|
||||
<artifactId>awaitility</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tngtech.archunit</groupId>
|
||||
<artifactId>archunit-junit5</artifactId>
|
||||
<version>1.3.0</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Excel Bearbeitung (Apache POI) -->
|
||||
<dependency>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package org.raddatz.familienarchiv.shared;
|
||||
|
||||
import com.tngtech.archunit.base.DescribedPredicate;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.junit.AnalyzeClasses;
|
||||
import com.tngtech.archunit.junit.ArchTest;
|
||||
import com.tngtech.archunit.lang.ArchRule;
|
||||
import jakarta.persistence.Entity;
|
||||
import org.raddatz.familienarchiv.FamilienarchivApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
@AnalyzeClasses(packagesOf = FamilienarchivApplication.class)
|
||||
class ArchitectureTest {
|
||||
|
||||
// Rule 1: Controllers must never inject repositories directly.
|
||||
// Security rationale: bypassing the service layer skips @RequirePermission
|
||||
// AOP checks that are enforced on service methods.
|
||||
@ArchTest
|
||||
static final ArchRule no_controller_injects_repository_directly =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(RestController.class)
|
||||
.should().dependOnClassesThat().areAssignableTo(JpaRepository.class);
|
||||
|
||||
// Rule 2: Services access only their own domain's repositories, never a foreign domain's.
|
||||
// Prevents hidden coupling between domains that should communicate via service APIs.
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_document =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..document..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("document"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_person =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..person..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("person"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_tag =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..tag..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("tag"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_user =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..user..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("user"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_dashboard =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..dashboard..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("dashboard"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_geschichte =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..geschichte..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("geschichte"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_notification =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..notification..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("notification"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_ocr =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..ocr..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("ocr"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_importing =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..importing..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("importing"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_audit =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..audit..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
||||
|
||||
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||
// where it can be audited and reasoned about independently.
|
||||
@ArchTest
|
||||
static final ArchRule no_configuration_class_in_domain_packages =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Configuration.class)
|
||||
.and().areNotAnnotatedWith(SpringBootApplication.class)
|
||||
.should().resideInAnyPackage(
|
||||
"..document..", "..person..", "..tag..",
|
||||
"..geschichte..", "..notification..", "..ocr..",
|
||||
"..filestorage..", "..importing..", "..dashboard..", "..audit.."
|
||||
);
|
||||
|
||||
// Rule 4: Entities belong to their domain packages, not to a shared model layer.
|
||||
// Prevents regression to a flat, layer-based package layout.
|
||||
@ArchTest
|
||||
static final ArchRule entities_reside_in_domain_packages =
|
||||
classes()
|
||||
.that().areAnnotatedWith(Entity.class)
|
||||
.should().resideInAnyPackage(
|
||||
"..document..", "..person..", "..tag..", "..user..",
|
||||
"..geschichte..", "..notification..", "..ocr..", "..audit.."
|
||||
);
|
||||
|
||||
// TODO Rule 5: Controllers expose endpoints under their domain prefix
|
||||
// (e.g., classes in ..document.. are annotated with @RequestMapping("/api/documents")).
|
||||
// Implementing this requires a custom ArchUnit DescribedPredicate inspecting the
|
||||
// @RequestMapping annotation value — deferred due to brittleness concerns.
|
||||
// Tracked in: http://heim-nas:3005/marcel/familienarchiv/issues/427
|
||||
|
||||
private static DescribedPredicate<JavaClass> foreignJpaRepositoryFor(String ownDomain) {
|
||||
// Exact-segment match: prevents a domain name that is a substring of another
|
||||
// (e.g. "tag" inside "tagging") from silently escaping the predicate.
|
||||
// The pattern matches the domain as a complete path segment, with an optional sub-package.
|
||||
String ownPackagePattern = ".*\\.familienarchiv\\." + ownDomain + "(\\..+)?$";
|
||||
return new DescribedPredicate<JavaClass>("be a JPA repository from a domain other than " + ownDomain) {
|
||||
@Override
|
||||
public boolean test(JavaClass clazz) {
|
||||
return clazz.isAssignableTo(JpaRepository.class)
|
||||
&& !clazz.getPackageName().matches(ownPackagePattern);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user