ADR-0119: Module-Internal Package Structure — Capabilities First, Layers Second, the Root Is the Surface¶
- Status: accepted (2026-08-22 — his request 2026-08-21 "structure the code in reasonable packages … the logical layering could be a good guideline", the proposal's capability taxonomy agreed, his hybrid challenge accepted with its reasoning recorded, the transfer-object resolution agreed, "I also do not see anything that needs to be renamed")
- Date: 2026-08-22
- Deciders: Nicolas Burri
- Relates to: ADR-0005 (module boundaries — the strong wall this deliberately does NOT
duplicate), ADR-0036 (layering: transactions in services, jOOQ only in repositories — the layer
vocabulary this closes over), the 2026-08-18 architecture review (guard rails stop at module
boundaries;
scopecarries two responsibilities), and module-package-structure-proposal.md (the full taxonomy, the discussion record, and the war story behind §4).
Context¶
No module has any internal package structure: document is 98 files / 32k lines in one flat
directory, api is 81. Nicolas's diagnosis, which this ADR adopts as a design principle: a
structure in which placement is hard surfaces fragments at the wrong level of abstraction —
the difficulty of placement is the diagnostic, not an inconvenience.
Decision¶
-
First level: capability packages. The proposal's taxonomy as agreed (for
document:core,lifecycle,content,blob,collab,records,training,comment,trace,kind,export,arrival,relation,setup— training split out of records, because the split that carries meaning is capability coherence, not directory size). No renames. Module root keeps only the module marker. -
Second level: layers, closed at ADR-0036's two names —
service/andrepository/. His two arguments overturned the draft's one-level rule and are recorded as deciding: (a) the structure forces the layer decision at file-creation time instead of leaving it an afterthought — the opinionated-system principle applied to our own code; (b) membership-shaped rules are only typo-proof over packages —MySuperRepositroyevades every name-based "repositories must…" rule but cannot evade living in.repository. -
The capability root is the published surface. Domain vocabulary (
TraceItem,ElementKind, …) lives at the root; machinery lives in the layer packages. Promoting an internal type to shareable = physically moving it up a level: one reviewable diff line, never import erosion. -
No
api/modelpackages. The known degeneration (his prior projects:modelmigrates toapibecause promotion is free when it happens and costs later) is dodged structurally: the strong wall stays at the MODULE boundary (ADR-0005/Konsist/the §5 checker); within a module exactly one rule with teeth: no capability imports a sibling'srepository/. Root andservice/stay sibling-importable, so there is nomodelpackage to migrate from. Kotlin file-private types absorb the cross-layer-internal population (theprivate data classhabit the codebase already has); behavioral seams are ports defined in the consumer's vocabulary (theDraftCollabSessionspattern). -
Konsist enforces all of it in the same wave as the moves: only
*.repositorypackages importch.lqms.persistence.jooq; no cross-capabilityrepository/imports; layer packages contain no third name. The §5 diagram checker (ModuleDiagramTest) must stay green — module edges unchanged by construction. -
Scope:
apigets capability packages but NO layer level (resources are its one layer);catalogsplits intotypes/membership/provisioning/person; modules at ≤ 9 files stay flat — a package tax on six files buys nothing.
Consequences¶
- Rides in one wave with the registered
scopesplit (shared exception vocabulary → a smallcommonmodule), sequenced before the export twin so new code lands in the new structure. - The moves are mechanical; the traceability matrix is regenerated in the same commit (paths change). Historical documents keep their as-written paths — they describe the tree at their date.
- The wave touches every import in the codebase: full merged gate + fullstack smoke before push.
- Four placement smells named in the proposal (
Csv/PackWriter,ElementKind/DesignKind,AuthorityView*,WorkflowActionEvaluator) are assigned pragmatically in the wave and kept on the register — the taxonomy makes their wrongness visible, which is the point; relocating them across modules is separate, deliberate work.