ADR-0120: The Shared Kernel — the Domain-Exception Vocabulary Leaves the Authorization Module¶
- Status: accepted (2026-08-22 — decided in principle by Nicolas on 2026-08-21: "I also
really like the idea of separating scope", after the architecture review measured what
scopewas actually carrying) - Date: 2026-08-22
- Deciders: Nicolas Burri
- Relates to: ADR-0005 (modular monolith — the module set this adds one leaf to), ADR-0012
(authorization — what
scopekeeps), ADR-0026 (RLS connection-pool safety —RlsScopeContext, which deliberately stays), ADR-0119 (module-internal package structure — this rides in the same wave, and it is the cross-module half of the same tidying), and the 2026-08-18 architecture review (docs/planning/review-2026-08-18-architecture.md).
Context¶
The 2026-08-18 architecture review named scope the codebase's centre of gravity: 82 importing
files, 256 imports. Its finding was not that the number is large but that it is two numbers
added together — scope "carries both authorization and the shared exception vocabulary, two
unrelated responsibilities in one hub".
The measurement (main-source importing files, counted 2026-08-21) separates them cleanly:
| Export | Files | Belongs to |
|---|---|---|
AuthorizedRequestContext |
56 | authorization |
EffectiveAuthzRepository |
53 | authorization |
ScopeRepository |
14 | authorization |
RecordsCapability |
2 | domain rule of the scope model |
NotFoundException |
38 | shared kernel |
requireValid |
27 | shared kernel |
DomainValidationException |
14 | shared kernel |
checkState |
12 | shared kernel |
PermissionDeniedException |
12 | shared kernel |
GlobalScopeMissingException |
12 | shared kernel |
InvalidStateException |
11 | shared kernel |
The right-hand column is the whole argument. The exception types are the F24 consolidation of the
per-module copies every module used to keep; they were parked in scope because that was the one
module everybody already depended on. Nothing about them is scope-shaped. The consequence is that a
module wanting to throw a 400 had to declare a dependency on the authorization module — so
the dependency graph asserted a coupling that does not exist, and every reading of it (including
arc42 §5.1) overstated how central authorization is.
Decision¶
Extract the shared exception vocabulary into a new leaf module backend/common, package
ch.lqms.common. It holds exactly one file of substance, DomainExceptions.kt:
Amendment (2026-09-02): a second file of substance was admitted —
JsonText.kt, the one shared JSON escaper (review D-2/R-1: six drifting hand-rolled copies consolidated). The admission rule below held (no DB, no scope, no request context; every caller module already depends oncommon, zero new edges); "exactly one file" is since then a count, not a rule.PermissionDeniedException,NotFoundException,GlobalScopeMissingException,InvalidStateException,DomainValidationException, and therequireValid/checkStateguards that raise the last two.
commondepends on nothing — not evenpersistence. A module that depends on nothing can be depended on by everything without ever creating a cycle, and the emptiness of its dependency block is the rule that keeps it a kernel: anything needing a database, a scope or a request context does not belong here.- What deliberately stays in
scope: RlsScopeContext— Nicolas's explicit ruling (2026-08-21, fix-log decision 6): those 76 lines are the tenant-separation rule expressed as code, not plumbing. Separation isscope's reason to exist; moving its enforcement into a "utilities" module would be exactly the kind of convenience the project's first non-negotiable rule forbids.TransactionPostCommit— stays for now. It is coupled toRlsScopeContext's manual JDBC transaction (ADR-0050 §1), so it goes where that goes; two importers make the question small.AuthorizedRequestContext,EffectiveAuthzRepository,ScopeRepository,RecordsCapability,RlsSubjectRoleDeploymentCheck— authorization and the scope model. That isscope, and after this extraction it is onlyscope.- Nothing is renamed. The move is a package change and an import sweep; class names, exception messages and KDoc are untouched except where they named the old package.
Consequences¶
scopebecomes purely authorization + tenancy. A… → scopeedge now means one thing. The importer counts behind the surviving edges fall accordingly (api → scope22 files → 5,audit → scope3 → 1,document → scope44 → 40).- The module graph grows, it does not shrink: 40 → 48 import edges. Every module that used
scopefor an exception also uses it for authorization, so no… → scopeedge disappeared and eight… → commonedges appeared (api,document,catalog,scope,search,backup,notification,audit). This is the honest outcome, and worth stating plainly: the split was never going to reduce edge count — it makes each remaining edge mean what it says. - arc42 §5.1 and its table are updated in the same commit, which is now mechanically enforced:
ModuleDiagramTestparses the §5.1 Mermaid block and diffs it against the real imports, so a missed node or edge fails the build rather than waiting for a reader. - Konsist is unaffected.
ch.lqms.commonis under thech.lqmsproduction root, holds no JAX-RS resource, no repository, no file-system access and no privileged connection type — soModuleBoundaryTestandPrivilegedDataAccessInventoryTestneed no new entry, and neither ever namedch.lqms.scopefor the exceptions. - Historical documents keep their as-written paths (ADR-0119's rule): STATUS, the worklog, the
fix log and earlier ADRs — e.g. ADR-0081's
ch.lqms.scope.checkState— describe the tree at their date and are not rewritten. - The traceability matrix is regenerated in the same commit, since a moved file changes the evidence paths it cites.