refactor(backend): ArchUnit Rule 5 — enforce controller @RequestMapping URL prefix per domain #427
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
Deferred from #409 (ArchUnit domain boundary tests). Rules 1–4 are live. Rule 5 was flagged as potentially brittle and is tracked here separately.
Required rule
Controllers in a given domain package must expose endpoints under that domain's URL prefix. Example: a controller in
..document..must carry@RequestMappingwhose value starts with/api/documents.Why deferred
A custom
DescribedPredicate<JavaAnnotation<?>>inspecting annotation string values is ~20 lines per domain and fragile when URL patterns change (e.g., versioning:/api/v2/documents). The team chose TODO + follow-up over a brittle rule on day one.Acceptance criteria
ArchitectureTest.javais extended with per-domain URL prefix rules (or a loop over a domain→prefix map)ArchitectureTest.javais removed and replaced with the live rule./mvnw testruns the new rules as part of the suiteDependency
Depends on #409 being merged first.
🏛️ Markus Keller — Application Architect
Observations
documentpackage controller quietly exposes endpoints under/api/users/....NotificationControllerandOcrControlleruse per-method full-path@GetMapping("/api/notifications/...")rather than a class-level@RequestMapping.UserControllerhas@RequestMapping("/api/")(trailing slash, nouserssegment).TranscriptionQueueControlleris in..document.transcription..but maps to/api/transcription. These are real violations of the invariant the issue is trying to enforce — meaning the rule, once live, will fail immediately without first normalising those controllers.DescribedPredicateper domain — is a maintenance trap. The existing Rule 2 already demonstrates the cost: 10 identical rule declarations, one per domain, each registering a separate@ArchTestfield. Adding another 10 for URL prefixes multiplies the surface area.Map<String, String>domain→prefix lookup driving a@ParameterizedTest-style loop is the correct pattern here. ArchUnit's@ArchTestdoes not support parameterised tests directly, but a single@ArchTestfield can iterate a map and callrule.check(classes)programmatically, accumulating violations before throwing. This gives one concise declaration with full coverage.CLAUDE.mdupdates are triggered. However, if the implementation normalisesNotificationControllerorOcrControllermapping style as a prerequisite, that is a separate concern from the ArchUnit rule itself.Recommendations
OcrControllerandNotificationControllershould gain a class-level@RequestMapping, andTranscriptionQueueController's/api/transcriptionprefix should be evaluated (it arguably belongs under/api/documents/transcription). Do this in a separate commit before the ArchUnit rule so the rule passes green from the first run.@ArchTestdriven by aMaprather than 10+ separate field declarations. KeepDOMAIN_TO_PREFIXas a private static constant — it becomes the authoritative registry of all domain→URL mappings in the codebase.AnnotationControllerunder..document.annotation..mapping to/api/documents/{id}/annotations) through this rule — the prefix check at the top-level domain package boundary is sufficient and avoids exponential complexity.Open Decisions
TranscriptionQueueControllerprefix: it currently maps to/api/transcriptiondespite living in..document.transcription... The rule as specified would require/api/documents/.... Is that the desired API path, or should the domain→prefix map registertranscriptionas a special case with its own prefix? This needs a decision before Rule 5 can go green.UserControllerbase path:@RequestMapping("/api/")is ambiguous — the trailing slash means the class-level prefix is effectively empty. Should it become@RequestMapping("/api/users")for consistency, or does its mixed/api/users/...+/api/...routing have a specific reason?👨💻 Felix Brandt — Senior Fullstack Developer
Observations
DescribedPredicate<JavaAnnotation<?>>API in the issue body is slightly outdated. ArchUnit 1.x (the project uses1.3.0) providesJavaAnnotation.get("value")returningOptional<Object>, but the cast toObject[]is only safe for multi-value attributes. A class-level@RequestMapping("/api/documents")storesvalueas aString[](since Spring 6), not a rawObject[]. The predicate should use(String[])or iterate withOptional.flatMap. Getting this wrong silently produces aClassCastExceptionthat masks the rule failure.foreignJpaRepositoryForhelper in the existingArchitectureTestuses a regex-basedDescribedPredicate<JavaClass>, not one onJavaAnnotation. The new Rule 5 requires aDescribedPredicate<JavaAnnotation<?>>, which is a different generic bound. The two approaches are not composable — the new predicate needs its own factory method with a clear name.@ArchTestfields repeating identical structure. Rule 5 should not repeat this pattern. A single@ArchTestwith an internal loop overMap.of(...)entries andArchRuleDefinition.classes()checked per entry is cleaner and easier to extend.OcrControllerandNotificationControllerboth lack class-level@RequestMapping. These controllers use full-path per-method mappings (@GetMapping("/api/ocr/...")). The Rule 5 check needs to handle this: a controller without class-level@RequestMappingshould either fail the rule (forcing normalisation) or be explicitly excluded with a comment. Silently passing it would undermine the rule's value.UserControllerhas@RequestMapping("/api/")— the trailing slash means the class-level path is/api/, not/api/users. The prefix checkstartsWith("/api/users")would fail for this controller.TranscriptionQueueControllerin..document.transcription..maps to/api/transcription, not/api/documents. Rule 5 as spec'd would flag this as a violation.Recommendations
JavaAnnotation.get("value").map(v -> ((String[]) v)[0])in the predicate body — cast toString[], notObject[], since Spring's@RequestMapping(value = "...")usesString[]semantics.urlPrefixRuleFor(String domain, String prefix)and keep it private static alongsideforeignJpaRepositoryFor. One method per concern, descriptive name.@RequestMappinga rule failure — a controller without it should not pass the domain-prefix rule. This forces the normalisation ofOcrControllerandNotificationControlleras a prerequisite (per Markus's recommendation).ArchitectureTestRule5Testwith a mockJavaClassstubbed to carry a wrong prefix, assert the rule flags it, then implement the predicate to go green.ArchitectureTest.javawhere it is used. Co-location is the readable choice; the map is a test concern, not a runtime concern.Open Decisions
🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
..document..that accidentally exposes endpoints under/api/admin/...could bypass the coarser Spring Security route-based rules that protect the/api/admin/**prefix. Rule 5 prevents that class of misconfiguration structurally.@RequirePermissionAOP rather than route-based Spring Security path matchers. This means the URL prefix rule's security value is indirect: it enforces legibility and prevents namespace collisions, but does not directly enforce permission boundaries. The@RequirePermissionannotation is the real access gate. However, route namespace collisions can create confusion during security audits — an endpoint appearing in the wrong namespace is harder to audit for correct permission coverage.@RequestMappingis normalised at the class level forOcrControllerandNotificationController(which this issue may trigger as a prerequisite), the per-method full-path mappings become shorter and more obviously scoped. Long full-path strings like@GetMapping("/api/documents/{documentId}/ocr-status")scattered across a controller are harder to audit for completeness than@GetMapping("/{documentId}/ocr-status")under a class-level@RequestMapping("/api/documents").OcrController.trainandOcrController.segtrainexpose powerful endpoints. Their URL paths (/api/ocr/train,/api/ocr/segtrain) are correct for their domain. No cross-namespace issue here.Recommendations
@RestControllerannotated with@RequirePermission(Permission.ADMIN)or similar high-privilege annotations lives under the expected URL namespace. The rule catches future drift; the one-time audit catches existing drift.OcrControllerorNotificationControllerviaignoredClasses. Force normalisation of those controllers instead. An@ArchTestwith exclusions is a security smell — exclusions accumulate and the rule loses its enforcement value.Open Decisions
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
./mvnw testruns it. No ambiguity.ArchRuleEvaluatorAPI to feed synthetic class metadata. Without this, the rule could silently pass even if the predicate has a bug (e.g., wrong cast, wrong annotation attribute name) — the predicate would be evaluated but never trigger because all real controllers already comply.@ArchTest+@AnalyzeClasses. It integrates cleanly into the Maven Surefire phase — no additional configuration required. The acceptance criterion "runs as part of the suite" will be satisfied automatically.@AnalyzeClassescall is already paying the class-loading cost for Rules 1–4; Rule 5 adds a filter + predicate evaluation on the same loaded class set. No meaningful CI time increase expected.@AnalyzeClasses(packagesOf = FamilienarchivApplication.class)— this is correct. The class cache is shared across all@ArchTestfields in the class, so loading happens once per test class.ArchitectureTest.java(lines 128–133) is precise and links back to this issue. Removing it when the rule goes live is a clean, verifiable acceptance criterion.Recommendations
ClassFileImporterto load a synthetic wrong-prefix controller (e.g., a test-scope class annotated with@RestController+@RequestMapping("/api/wrong")in the..document..package) and assertrule.check(importedClasses)throwsAssertionError. This proves the rule is not vacuously passing.@Disabled. If normalisingOcrController/NotificationControlleris out of scope for this issue, scope the rule to only check controllers that have class-level@RequestMapping(and document that exclusion explicitly) until normalisation is done in a follow-up.TranscriptionQueueController(/api/transcriptionin..document.transcription..) before merging. If it does fire, either fix the prefix or add it to the exclusion map with a ticket reference.Open Decisions
@RequestMapping(allowing incremental rollout)? Both are valid; the former is cleaner long-term, the latter avoids blocking this PR on unrelated refactoring. This is a planning decision for the implementer.🖥️ Tobias Wendt — DevOps & Platform Engineer
Observations
ArchitectureTest; the new rule participates automatically.@ArchTestfields withinArchitectureTestvia@AnalyzeClassescaching. Adding Rule 5 adds microseconds of predicate evaluation to the existing class-loading cost. No pipeline timing concern.archunit-junit5:1.3.0version inpom.xmlis current as of this issue date. No version bump needed.OcrController,NotificationControllerto class-level@RequestMapping) is done in the same PR, it changes the URL routing behavior at runtime. That warrants a smoke test pass before merge to confirm the endpoints are still reachable at their existing paths. The class-level@RequestMappingplus shortened method-level paths must produce the same composed URL as the current full-path per-method annotations — this is a runtime verification concern, not a compile-time one../mvnw test" is already satisfied by virtue of being a@ArchTestin the existing@AnalyzeClassestest class. No special CI configuration is needed.Recommendations
backend/api_tests/) that the affected endpoints (/api/notifications/...,/api/ocr/...) respond correctly after the@RequestMappingrefactor. Onecurlagainst the running stack is sufficient — this is not worth a full E2E Playwright run.shared.*tests — checkpom.xmlfor any<excludes>that might accidentally skipArchitectureTest. (From code inspection, none appear to exist, but worth a quick verify before the PR.)Open Decisions
📋 Elicit — Requirements Engineer
Observations
ArchitectureTest.javawhich exists because of #409. No ambiguity there.OcrControllerwhich spans both/api/documents/...and/api/ocr/...paths? This is an implementation ambiguity that will surface during development./api/v2/...). These are separate risks. Concern (a) is resolved by the loop approach. Concern (b) is a future risk that should be acknowledged in the rule's comment, not used as a reason to block the rule entirely.Recommendations
AssertionError(verified by a dedicated unit test)." This makes the criterion unambiguously testable.@RestController-annotated class). Exclude domains with no controllers to avoid vacuous rules./api/v2/...) is adopted, the prefix map will need updating — this is acceptable maintenance, not brittleness."Open Decisions
OcrControllercurrently spans two URL namespaces (/api/documents/{id}/ocrand/api/ocr/...). Should Rule 5 require a single class-level prefix (forcing normalisation or a design decision about which namespace OCR "belongs" to), or should multi-prefix controllers be explicitly excluded with a comment? This is a genuine product/architecture tradeoff requiring a decision before the rule can be finalised.🎨 Leonie Voss — UI/UX Design Lead
Observations
Recommendations
Open Decisions
🗳️ Decision Queue — Open Items Requiring Human Judgment
Raised by: Markus (Architect), Elicit (Requirements)
Theme 1: Controller URL Namespace Normalisation (Prerequisite to Rule 5 going green)
Three controllers currently violate the invariant Rule 5 would enforce:
OcrController..ocr../api/documents/{id}/ocr,/api/ocr/batch, ...)@RequestMappingwhose value starts with/api/ocrNotificationController..notification../api/notifications/...)@RequestMapping("/api/notifications")UserController..user..@RequestMapping("/api/")(trailing slash)@RequestMapping("/api/users")TranscriptionQueueController..document.transcription..@RequestMapping("/api/transcription")Decision needed: Do these get normalised as part of this issue (in a prerequisite commit), or does Rule 5 go live with an explicit exclusion list for outliers (tracked in a follow-up issue)?
Theme 2: OcrController's Dual Namespace
OcrControllerspans two URL namespaces — it serves both/api/documents/{id}/ocr(document-scoped) and/api/ocr/batch,/api/ocr/train(OCR-domain). A single class-level@RequestMappingcannot cover both.Decision needed: Should OCR split into two controllers (one under each namespace), or should
OcrControllerbe excluded from Rule 5 with a comment explaining the dual-namespace intent?Theme 3: Scope of Rule 5 at Merge Time (rollout strategy)
Two valid paths:
@RestControllerwith no exclusions. Cleaner long-term, but requires more work upfront.@RequestMapping(others are silently skipped). Smaller PR, but outliers remain unguarded until a follow-up.Decision needed: Which rollout approach is preferred for this issue?