test(backend): add ArchUnit domain boundary enforcement (Rules 1–4) #428
Reference in New Issue
Block a user
Delete Branch "feat/issue-409-archunit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Closes #409. Adds
ArchitectureTest.javawith four ArchUnit rules that fail the build if domain boundaries are violated in the future.@RestControllermay inject aJpaRepositorydirectly; preserves@RequirePermissionAOP enforcement on the service layer@Serviceclasses access only their own domain's repositories, never a foreign domain's (enforced per-domain: document, person, tag, user, dashboard, geschichte, notification, ocr)@Configurationclass (except@SpringBootApplication) may reside in a domain package; keeps infrastructure config auditable inconfig/andsecurity/@Entityclasses reside in a named domain package; prevents regression to flat layer-based packagingEach rule was verified with a deliberate-break round-trip: violation introduced → test RED → violation reverted → test GREEN.
⚠️ Merge order: this branch includes the domain-packaging commits from #407. Merge after #407 and #408 land, or rebase onto the merged main.
Test plan
./mvnw testpasses: 1514 tests, 0 failuresTagRepositoryfield intoTagController→no_controller_injects_repository_directlyfiresPersonRepositoryintoTagService→services_only_access_own_domain_repositories_tagfires@Configurationclass indocument/package →no_configuration_class_in_domain_packagesfires@Entityclass inconfig/package →entities_reside_in_domain_packagesfires🤖 Generated with Claude Code
🏗️ Markus Keller (@mkeller) — Application Architect
Verdict: ✅ Approved
This is exactly the kind of structural safeguard that pays for itself on the first regression it prevents. ArchUnit enforces what ADRs can only document — and that is the right trade-off. Four rules, clearly justified, verifiably working (RED/GREEN round-trip documented in the PR). Good call.
Blockers
None.
Suggestions
1.
importingis missing from Rule 3's domain list (L97–103)MassImportServicelives in..importing.., but the domain exclusion list inno_configuration_class_in_domain_packagesdoes not include..importing... The rule currently says: a@Configurationclass inimporting/would NOT fire. That is likely a gap — if someone drops a@Configurationthere it goes undetected.Wait — re-reading:
importingIS in the list. Rule 3'sshould().resideInAnyPackage(...)is a negation (noClasses().that()...should().resideInAnyPackage(...)) — it fires if a@Configurationdoes reside in those packages. Soimportingis already covered. Good.2. Rule 3 word order reads backwards — suggest a comment clarification
The rule reads: no
@Configuration(except@SpringBootApplication) should reside in any of the listed packages. The logic is correct (noClasses...should().resideInAnyPackage()), but a new reader might expect theshouldclause to list the allowed packages (like Rule 4 does). A one-liner noting this is a prohibition, not an allowlist, would save confusion. Minor.3.
importingandauditare omitted from Rule 2's per-domain coverageRule 2 generates one
@ArchTestfield per domain. Theimportingandauditpackages have services (MassImportService, presumably some audit service), but they have no corresponding rule. IfMassImportServiceever injects a repository fromdocument/, Rule 2 will not catch it. Worth adding entries for at leastimporting—auditmay legitimately cross boundaries as a cross-cutting concern, so that one is debatable.4. Consider a single parameterized test over 8 near-identical fields (suggestion, not blocker)
Eight
static final ArchRule services_only_access_own_domain_repositories_*fields with identical structure signal a missed abstraction. ArchUnit supports@ArchTestscollections — aList<ArchRule>helper factory keeps the intent clear and is easier to extend when a new domain is added:This is a KISS trade-off and the current verbosity is defensible for readability — but worth a discussion when adding domain 9+.
5. Rule 5 deferral is well-reasoned
The TODO comment is precise: it names the problem (inspecting annotation values is brittle), links to the tracking issue (#427), and is in the test file where a future implementer will find it. This is the right way to defer work.
Overall: solid implementation of a long-overdue structural guard. Merge after #407/#408 as noted.
👨💻 Felix Brandt (@felixbrandt) — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
The rules are correct and the comments explain the why — that is exactly what I want to see. But there are two clean-code concerns I cannot let slide quietly, and one pattern that will silently miss violations.
Blockers
1.
foreignJpaRepositoryFor()has a subtle string-matching bug that can produce false negativesArchitectureTest.javaline 118–123:The check
getPackageName().contains("." + ownDomain)matches if the string".document"appears anywhere in the fully-qualified package name. It works for direct subdomain repos (e.g.,org.raddatz.familienarchiv.document.DocumentRepository). But it would silently pass (not fire) for a hypothetical future domain whose name contains another domain name as a substring — for example, if someone createddocument_typeordocumentmetadomain and the check against"document"accidentally matched it. This is low probability today but the predicate is not as tight as it could be.A safer check:
Or use ArchUnit's built-in
resideInAPackage(".."+ownDomain+"..")on the class directly rather than a custom predicate. I'd call this a blocker because it is the correctness foundation of Rule 2.Suggestions
2. Eight near-identical
@ArchTestfields — apply DRYArchitectureTest.javalines 36–95: eightstatic final ArchRule services_only_access_own_domain_repositories_*fields with structurally identical bodies. If a ninth domain is added, it is easy to forget the test. Each field is 6 lines; the whole block is 48 lines of repetition.ArchUnit's
@ArchTests-annotated class or a static factory returning a collection would reduce this to a single call site. The rule descriptions can still be domain-specific via.as("services_only_access_own_domain_repositories_" + domain).3. Package placement for the test
ArchitectureTest.javalives inorg.raddatz.familienarchiv.shared. The production code has nosharedpackage — this is a test-only namespace. That is fine, but naming itarchitecture(matching the file name) would be more self-documenting:Minor — no behavioral impact, but
sharedis ambiguous (shared with what?).4. The
DescribedPredicateanonymous inner class could be a lambdaArchUnit 1.3.0 supports lambdas for
DescribedPredicate. The anonymous inner class at lines 114–121 works but is more verbose than necessary:This is a suggestion only — the anonymous class is valid and readable.
🔧 Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Verdict: ✅ Approved
Two-file PR, both test-scoped. No changes to
docker-compose.yml, no new services, no new environment variables, no infrastructure impact whatsoever. My checklist is short.Blockers
None.
Suggestions
1. ArchUnit version is pinned — good. Check if it is in BOM.
pom.xmlline 115:<version>1.3.0</version>is explicitly pinned, which is correct practice. However, Spring Boot 4's parent BOM does not yet manage ArchUnit (it is not a Spring-ecosystem artifact), so a manual version pin is the right call here. Just make sure Renovate (if configured) has visibility into this<version>tag so it will create a bump PR when 1.4.x or 2.x lands. If there is arenovate.jsonin the repo root, verify it coverscom.tngtech.archunit— ArchUnit publishes frequently.2. CI time impact is negligible
ArchUnit bytecode analysis on a codebase this size typically adds 2–5 seconds to the test run. The PR description says 1514 tests pass — that is well within the
<10 secondsunit test budget. No concern there.3. The
<scope>test</scope>on the dependency is correctNo accidental production classpath bloat. ArchUnit only runs during
./mvnw test/./mvnw package— it will not be in the deployed JAR. Confirmed.Overall: clean infrastructure footprint. Ship it after the domain PRs land.
📋 Elicit — Requirements Engineer
Verdict: ✅ Approved
This PR closes issue #409. I evaluated whether the delivered rules match the stated need (preventing domain boundary regressions) and whether anything relevant was left out or ambiguously specified.
Blockers
None.
Observations
1. Requirement coverage — Rules 1–4 are verifiable and map to stated goals
Each rule has a stated rationale, a test name, and per the PR description was verified with a deliberate RED test. The acceptance criteria from issue #409 appear to be met. Rule 5 is explicitly deferred with a tracked issue reference (#427) — that is a clean deferral, not a silent gap.
2. The
importingdomain has no Rule 2 coverageMassImportServicelives in..importing... Noservices_only_access_own_domain_repositories_importingrule exists. If the business requirement is "no service reaches a foreign repository," thenMassImportServiceis currently ungoverned by Rule 2. This may be intentional (mass import legitimately delegates to other services), but it is an undocumented gap.Recommendation: Add a comment or a tracking note explaining why
importingis excluded from Rule 2. If it is intentional, documenting it prevents a future maintainer from adding the rule unnecessarily — or worse, assuming it was forgotten and adding it when it shouldn't be.3. The
auditdomain's omission from Rule 2 is also undocumentedSimilarly, cross-cutting concerns like audit logging often need access to multiple domains. If
AuditServiceis intentionally exempt from the domain-isolation constraint, a comment in the test saying so would make that decision visible to future reviewers.4. Rule 2's per-domain enumeration creates a "new domain tax"
When a new domain is added (e.g.,
export/,sharing/), a developer must remember to add a new@ArchTestfield. There is no mechanism to enforce that the list stays complete. A self-maintaining rule (e.g., derived from scanning all@Servicepackages at test time) would be more robust — but may be out of scope for this PR. Worth tracking as a follow-up.5. The merge-order note in the PR description is a process risk
The PR description states: "Merge after #407 and #408 land." This is a manual dependency that could be missed. If Gitea supports blocking merges on PR dependencies, that would be safer than a prose note. If not, this is an acceptable risk for a small team.
Overall: requirements are met for the stated scope. The gaps around
importingandauditdomain coverage in Rule 2 are worth a clarifying comment at minimum.🔒 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
This PR is a security control, not just a code quality tool. Rule 1 directly prevents bypassing
@RequirePermissionAOP enforcement — that is a meaningful defense against a real attack class (privilege escalation via repository injection). I want to confirm the rule is watertight.Blockers
None. But read the two observations carefully.
Security Analysis
1. Rule 1 correctly targets the
@RequirePermissionbypass vectorThe rationale in the code comment is accurate:
AOP interception via Spring's proxy mechanism only fires when method calls pass through the proxy. A
@RestControllerthat injects aJpaRepositorydirectly can read or write data without ever touching the permission check. Rule 1 makes this architectural pattern compile-fail. This is exactly the right layer to enforce it.2. Rule 1 only catches field-level dependency (
dependOnClassesThat) — does it cover indirect access?noClasses().that().areAnnotatedWith(RestController.class).should().dependOnClassesThat().areAssignableTo(JpaRepository.class)fires when the controller compiles against a JpaRepository subtype — which covers field injection, method parameters, and local variable declarations. It does not cover a controller calling a static method that internally accesses the repo without declaring it as a dependency. That edge case is highly unlikely in a Spring project (Spring repositories are not statics), but worth knowing the scope.3. Rule 2 provides a second defense layer for cross-domain data leaks
Cross-domain repository access could allow a service to silently read data it has no business reason to access — a form of insecure direct object reference at the service layer. Rule 2 catches this at compile time. Good defense in depth.
4. The
foreignJpaRepositoryForpredicate uses string substring matching!clazz.getPackageName().contains("." + ownDomain)— as Felix also flagged, this is a correctness concern. From a security perspective: if the predicate produces a false negative (incorrectly returnsfalsewhen it should returntrue), Rule 2 silently fails to fire on a real violation. A more precise package match (exact segment match rather than substring) would make this a tighter security control.Recommended fix (same as Felix's):
5. No security regression tests needed for this PR
ArchUnit tests are themselves the regression tests. The RED/GREEN verification round-trips documented in the PR description confirm they fire on real violations. No additional security tests are warranted.
Overall security posture: this is a structural control that prevents future developers from accidentally weakening the permission enforcement boundary. It is the right tool at the right layer. The substring matching concern in the predicate is worth fixing — it is the only thing standing between "rule fires" and "rule silently misses a violation."
🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
The ArchUnit tests are a welcome addition to the static analysis layer of the pyramid. They are fast (bytecode analysis, no Spring context), deterministic, and self-documenting. My concerns are about test completeness and a gap that could allow the rules to produce false confidence.
Blockers
1. The
importingandauditdomains are missing from Rule 2 coverageArchitectureTest.javadefines eight per-domain@ArchTestfields for Rule 2, but neitherimportingnorauditis included. IfMassImportServiceor any audit class were to injectPersonRepositorydirectly, Rule 2 would not catch it. The test suite would stay green while a real boundary violation exists.Either:
services_only_access_own_domain_repositories_importingandservices_only_access_own_domain_repositories_audit, orThis is a blocker for test completeness — we should not ship a safety net with known holes.
Suggestions
2. The eight near-identical
@ArchTestfields reduce maintainability of the test itselfWhen a new domain is added, a developer must remember to add a new field here. There is no failing test that catches the omission — the test suite just silently lacks coverage for that domain. This is the opposite of the problem ArchUnit is supposed to solve.
A factory method that generates rules for all known domains (or discovers them by scanning
@Servicepackages) would make the rule self-maintaining. This is a known ArchUnit pattern.3. RED/GREEN verification is documented in the PR but not automated
The PR description lists four manual RED tests. This is good documentation, but those break-test rounds are one-time exercises. If someone refactors
foreignJpaRepositoryFor()and inadvertently weakens the predicate, there is no automated test that catches the regression in the rule itself (i.e., a meta-test that introduces a violation and asserts the rule fires).ArchUnit supports testing rules in isolation via
EvaluationResult— consider a test that:JavaClassrepresenting a boundary violationforeignJpaRepositoryFor("document").test(thatClass)returnstrueThis is a nice-to-have, not a blocker, but it would close the meta-testing gap.
4. Test location is consistent with the
sharedpackage namingorg.raddatz.familienarchiv.shared.ArchitectureTest— the package exists only in the test tree, which is fine. I would preferarchitectureovershared(less ambiguous), but this does not affect behavior.5. CI time: no concern
ArchUnit on this codebase adds ~2–5 seconds. The PR states 1514 tests pass, total. That is firmly within the unit-test time budget. No concern.
🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead & Accessibility Strategist
Verdict: ✅ Approved
This PR is entirely backend test infrastructure — no frontend code, no UI components, no Svelte templates, no Tailwind classes, no accessibility-relevant markup. There is nothing here for me to audit on the UI/UX or accessibility axis.
Blockers
None.
What I checked
frontend/— confirmed by the diff (2 files changed:backend/pom.xmlandbackend/src/test/.../ArchitectureTest.java)@Schemaannotation changesOne forward-looking note
Rule 5 (deferred, tracked in #427) proposes enforcing that controllers expose endpoints under their domain prefix. When that rule is implemented, it will indirectly constrain the API URL structure the frontend depends on. At that point, I would want to be looped in to confirm the URL conventions match what the SvelteKit route structure and the TypeScript API client expect. No action needed now — just flagging for when #427 is picked up.
Review feedback addressed — @felixbrandt
Two commits pushed to
feat/issue-409-archunit. Full suite: 1516 tests, 0 failures.Concern 1 —
foreignJpaRepositoryFor()exact-segment matching (Felix blocker + Nora concern)Fixed in commit
0dd58556.Replaced
getPackageName().contains("." + ownDomain)with a regex exact-segment match:This ensures a domain name that is a substring of another (e.g.
"tag"inside"tagging") cannot silently escape the predicate and produce a false negative. The pattern anchors on the fullfamilienarchiv.<domain>segment with an optional sub-package suffix.Concern 2 —
importingandauditmissing from Rule 2 (Sara blocker + Markus observation)Fixed in commit
09813552.Added
services_only_access_own_domain_repositories_importingandservices_only_access_own_domain_repositories_auditas@ArchTestfields alongside the existing eight.Both pass cleanly:
MassImportServicedelegates toDocumentService,PersonService, andTagService— no direct foreign repository access.AuditServiceandAuditLogQueryServiceonly touch their ownAuditLogRepository/AuditLogQueryRepository.Concern 3 — Eight near-identical fields, prefer a single parameterized rule (Markus suggestion + Felix suggestion + Sara suggestion)
Intentionally kept as per-domain fields.
ArchUnit's
@ArchTestfields must bestatic finalfor the JUnit 5 engine to pick them up. A factory returning aList<ArchRule>cannot be annotated with@ArchTestdirectly — the runner would not discover those rules at all. The KISS call is to keep explicit per-domain fields (now 10 of them) rather than introduce a custom@ArchTests-annotated collector class that complicates the structure. When domain 11 arrives, the pattern is clear from the existing ten.Test count went from 11 → 13 in
ArchitectureTest(two new@ArchTestfields), and from 1514 → 1516 across the full suite.