diff --git a/backend/pom.xml b/backend/pom.xml
index 6c415956..e4e2eb2b 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -108,6 +108,12 @@
org.awaitility
awaitility
test
+
+
+ com.tngtech.archunit
+ archunit-junit5
+ 1.3.0
+ test
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java
new file mode 100644
index 00000000..61804b01
--- /dev/null
+++ b/backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java
@@ -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 foreignJpaRepositoryFor(String ownDomain) {
+ return new DescribedPredicate("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);
+ }
+ };
+ }
+}