test(backend): add ArchUnit domain boundary enforcement (Rules 1–4) #428

Merged
marcel merged 3 commits from feat/issue-409-archunit into main 2026-05-05 18:08:40 +02:00
2 changed files with 153 additions and 0 deletions

View File

@@ -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>

View File

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