refactor(backend): add ArchUnit test enforcing domain boundaries #409
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Context
Part of Epic #406 — Big-bang restructure. This is REFACTOR-3: add an ArchUnit test that fails the build if anyone violates the layering or cross-domain rules in the future. Without this, the structure that REFACTOR-1 (#407) achieves will silently rot.
Per the Legibility Rubric, this addresses C6.1, C6.2 (Critical) — structurally.
Required rules
The ArchUnit test class lives at
backend/src/test/java/.../shared/ArchitectureTest.javaand asserts:Rule 1 — No controller injects a repository directly
Rule 2 — No service depends on another domain's repository
Rule 3 — Cross-cutting code only in shared/
Rule 4 — Entities live in their domain package, not in a shared model/
Rule 5 — Controllers expose endpoints under their domain prefix
A weaker check (this one is harder to enforce mechanically — accept a TODO if too brittle):
Maven dependency
Add to
backend/pom.xml:Acceptance criteria
ArchitectureTestexists atbackend/src/test/java/.../shared/ArchitectureTest.java./mvnw testrunsArchitectureTestas part of the suite@Autowired DocumentRepository docRepoin PersonService) makes the test FAILDependency
Hard dependency on REFACTOR-1 (#407) — the rules assert the post-refactor structure.
Definition of Done
PR merged. Closing comment lists each rule with a one-line "what it prevents."
Dispatch
Best done by an agent comfortable with ArchUnit DSL. Verify each rule by intentionally breaking the code, confirming the test fails, then reverting.
🏗️ Markus Keller — Senior Application Architect
Observations
StatsControllerincontroller/StatsController.javacurrently injectsPersonRepositoryandDocumentRepositorydirectly. That's a Rule 1 hit (controller → repository), not a Rule 2 hit, but it's a real pre-existing violation. The issue says rules "pass on the current domain-based codebase (post REFACTOR-1 / #407)". That assumption needs verifying — the current pre-REFACTOR-1 codebase already has this violation. Make sure the rule is only enforced after #407 lands.controller/,service/,repository/) rather than domain-based. The ArchUnit rule text uses..document..,..person..etc., which won't match anything in the current structure — all repositories live inorg.raddatz.familienarchiv.repository. Rule 2 as written assumes the post-REFACTOR-1 domain-based package layout. This must be an explicit hard dependency: these rules are non-compilable gibberish until #407 ships.SecurityConfigandAsyncConfigresiding in..shared.config..or..shared... Those currently live inconfig/. Again, only valid post-REFACTOR-1...document..,..person..etc. Currently all entities are inmodel/. Same hard dependency on #407.Recommendations
@Tag("architecture")annotation so they can be skipped selectively until the post-REFACTOR-1 branch is in play:./mvnw test -Dgroups=architecturevs../mvnw test -DexcludedGroups=architecture...repository..(post-refactor) packages may only be accessed by services in the same domain package — not just the inverse. Belt-and-suspenders.ArchitectureTest.javashould live in thesharedpackage of the test tree:backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java. The issue specifies this correctly — just confirming the path aligns with the post-REFACTOR-1shared/package that will be created by #407.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
noClasses().that().resideInAPackage("..document..").should().dependOnClassesThat().areAssignableTo(JpaRepository.class).and().resideOutsideOfPackage("..document..")— this is correct ArchUnit chaining. Confirm the.and()there actually chains the.resideOutsideOfPackage()onto the repository predicate, not onto the "no classes" clause. In some ArchUnit versions, chaining onshould()clauses behaves differently from chaining onthat()clauses. Test by deliberately adding aPersonRepositoryimport in adocumentservice and verifying the rule fires.ArchitectureTestis fine and follows the existing naming convention (e.g.,ApplicationContextTest,RelationshipServiceTest).@AnalyzeClasses— ArchUnit requires this annotation on the test class to specify which packages to scan. The issue's code snippets don't show this, but it's mandatory. Example:Without it, ArchUnit scans nothing and every rule trivially passes (false green).
Recommendations
@AnalyzeClasses(packagesOf = FamilienarchivApplication.class)to the test class — this is the most common ArchUnit mistake that causes silent false-green rules.DescribedPredicate<JavaAnnotation<?>>to inspect the@RequestMappingvalue array and checkstartsWith("/api/documents"). This is ~20 lines of ArchUnit custom predicate code. If it feels brittle, the TODO approach is fine — just file a follow-up issue at the time of writing the TODO so it doesn't evaporate.should_<behavior>_when_<condition>convention:should_fail_when_controller_injects_repository_directly,should_fail_when_service_accesses_other_domain_repository, etc.ArchUnit JUnit 5tests run as standard@Testmethods, they integrate with./mvnw testautomatically with no extra config needed beyond the dependency.🧪 Sara Holt — QA Engineer & Test Strategist
Observations
ArchitectureTest.javain the test tree (confirmed via grep). Theshared/test package doesn't exist yet — it will be created by this issue. The pathbackend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.javais correct per the issue spec.@SpringBootTest.PersonControllercallingDocumentService). That would be a different kind of boundary leak. Raising as an observation for consideration, not a blocker.Recommendations
@ArchTest-annotated field per rule. ArchUnit JUnit 5 supports static@ArchTest ArchRulefields that run as individual test cases — this gives per-rule failure messages in CI rather than one monolithic test failure:This produces named failures like
"no_controller_injects_repository"in the test report — far easier to diagnose than a singleArchitectureTestfailure.StatsControllerinjectsDocumentRepositorydirectly").🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
@RequirePermissionchecks. If a controller calls a repository directly, the permission aspect (AOP) is not in the call path. This rule is therefore a structural enforcement of the authorization model.shared/) is also indirectly security-relevant:SecurityConfigmust stay in a predictable, auditable location. Scattering security configuration across domain packages would make security audits significantly harder.Recommendations
ArchitectureTest.javaclass, add a comment on Rule 1 explaining the security rationale, not just the architecture rationale:This makes the test self-documenting for future security audits.
@RequirePermissionmust appear on all controller methods that are not in a permit-all list. ArchUnit can enforce this. It would close the gap where a new controller method is added without any permission annotation. Flag for a follow-up issue rather than scope-creeping this one.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
archunit-junit5:1.3.0is a test-scoped Maven dependency. Zero Docker Compose, infrastructure, or runtime impact. Build pipeline impact: negligible (ArchUnit analysis on this codebase will run in under 2 seconds)../mvnw test— Gitea Actions CI picks it up with no workflow changes needed../mvnw test(not just./mvnw clean package -DskipTests). If the CI workflow currently skips tests to speed up builds,ArchitectureTestwill never run in CI. The AC says "runs as part of the suite" — verify this is true in the current workflow YAML.Recommendations
ArchitectureTestin the test results count (e.g.,Tests run: 47, Failures: 0→ should becomeTests run: 51, Failures: 0if 4 rules are added as separate@ArchTestfields).1.3.0— ArchUnit follows semantic versioning and has a stable API. Renovate will handle future patch bumps automatically.📋 Elicit — Requirements Engineer
Observations
StatsControlleralready violates Rule 1, interpretation (a) is clearly intended. The AC should say this explicitly.Recommendations
[ ] If Rule 5 is not implemented, a follow-up Gitea issue is created and linked here.🎨 Leonie Voss — UX Designer & Accessibility Strategist
No concerns from my angle. This issue is purely a backend test infrastructure task with no user-facing UI components, no frontend changes, and no accessibility surface. I checked: there are no new Svelte components, no new API endpoints, and no changes to any route that would affect the UI.
The only thing I'd note for completeness: the
ArchitectureTestclosing comment (required by the DoD) should use plain English descriptions that non-architect team members can also read. Avoid opaque shorthand like "prevents C6.1/C6.2 violations" without explaining what that means in one plain sentence per rule.🗳️ Decision Queue — Action Required
2 decisions need your input before implementation starts.
Merge Order / Dependency
..document..,..person..,..shared..) that don't exist in the current flat-layer structure. The rules will either fail to compile or trivially pass green on the pre-refactor codebase. Options: (a) implement the rules now on a branch that is rebased on top of #407's branch — merge together, (b) implement now with all rules@Disabledand a@Tag("architecture")guard, enabled only after #407 merges. Option (a) is cleaner and avoids a guarded-but-inactive test class landing in main. (Raised by: Markus, Elicit)Rule 5 — URL Prefix Enforcement
DescribedPredicatenow (~20 lines), giving full enforcement from day one, or (b) write a TODO comment inArchitectureTest.javaand open a follow-up issue immediately. Either is acceptable — the key is that the work is tracked. (Raised by: Felix, Elicit)Implementation complete — PR #428
All four rules are live in
backend/src/test/java/org/raddatz/familienarchiv/shared/ArchitectureTest.java.What each rule prevents
@ArchTestfieldno_controller_injects_repository_directly@RestControllerbypassing the service layer to call a repository directly — which would skip@RequirePermissionAOP checks and expose raw data access without authorizationservices_only_access_own_domain_repositories_{domain}@Servicein domain X reaching into domain Y's repository — cross-domain coupling that should go through domain Y's service API insteadno_configuration_class_in_domain_packages@Configurationclass (infrastructure/framework wiring) being scattered into a domain package instead of staying inconfig/orsecurity/where it can be auditedentities_reside_in_domain_packages@Entityclass being placed outside a domain package — regression to flat layer-based packaging that was deliberately removed in #407Rule 5 status
Deferred. TODO comment in
ArchitectureTest.javareferences #427, which contains the spec, the DescribedPredicate implementation sketch, and the acceptance criteria.Deliberate-break evidence
Each rule was verified with a violation round-trip before committing:
private final TagRepository tagRepositoryfield toTagController→no_controller_injects_repository_directlyfiredprivate final PersonRepository personRepositoryfield toTagService→services_only_access_own_domain_repositories_tagfiredDocumentSomeConfig.javawith@Configurationindocument/→no_configuration_class_in_domain_packagesfiredSomeEntity.javawith@Entityinconfig/→entities_reside_in_domain_packagesfiredTest count
Baseline: 1503 tests. After: 1514 tests (+11 ArchUnit rules), 0 failures.
Commits
22ec808b—test(backend): add ArchUnit domain boundary enforcement (Rules 1–4)