test(backend): add ArchUnit domain boundary enforcement (Rules 1–4)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m28s
CI / OCR Service Tests (push) Successful in 35s
CI / Backend Unit Tests (push) Failing after 3m17s
CI / Unit & Component Tests (pull_request) Failing after 3m25s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Backend Unit Tests (pull_request) Failing after 3m19s

Rules enforced:
- Rule 1: no @RestController may inject a JpaRepository directly (preserves @RequirePermission AOP enforcement)
- Rule 2: @Service classes access only their own domain's repositories, never a foreign domain's
- Rule 3: no @Configuration class (except @SpringBootApplication) in domain packages
- Rule 4: all @Entity classes reside in a domain package

Rule 5 (URL prefix per controller domain) deferred — tracked in #427.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 17:13:41 +02:00
parent 548df84219
commit 22ec808b2d
2 changed files with 135 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,129 @@
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"));
// 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) {
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().contains("." + ownDomain);
}
};
}
}