ADR-00022: Module seam pattern — in-process seam vs internal, with a REST-boundary zone
Approval Status
Section titled “Approval Status”Status flow: Proposed → Accepted → Reviewed → Approved (or Rejected), or Not Required. A superseded ADR keeps its file; only its status changes to Superseded by NNNNN.
| Governance body | Status | Last update |
|---|---|---|
| CTN Project Team | Proposed | 2026-07-06 |
| CTN Technical Advisory Board | Pending | — |
| CTN Steering Committee | Pending | — |
| BDI Conformance Team | Pending | — |
| BDI Framework Team | Pending | — |
RdN Question: I don’t see the relevancy of this ADR, at least not for CTN and broader BDI initiatives. TBH isn’t this just about basic development hygiene and adhering to coding standards/best practises.
Context and Problem Statement
Section titled “Context and Problem Statement”asr-service is a Quarkus modulith. Its top-level packages under org.bdinetwork.asr are a hybrid: business/domain packages (audit, document, verification, onboarding) sit beside layer-first packages (api, model, repository, spi, error, config). model and repository are prototype parking packages — layer-first staging expected to dissolve into their owning modules over time.
Nothing today constrains what one module may reach in another. A sibling module can @Inject any public bean or import any type of another module, including its JPA entities and internal services. As the codebase grows this invites accidental coupling, cycles, and a persistence layer that leaks across module boundaries. Concrete example found in the audit module: the write service’s seam method AuditRecorder.record(..., AuditRecord.Action, ...) forced callers ( DocumentService, VerificationStatusService) to import a JPA @Entity (AuditRecord) just to name an action enum.
We want each module to expose a small, explicit seam (what other in-process modules may reach) and hide everything else. This is a Quarkus project — there is no Spring Modulith — and Java’s own access control cannot express the boundary (see options below). ADR-00009 (layered source code structure) is orthogonal: it governs tenancy/reusability namespaces (org.bdinetwork.* core / marketplace / nl.ctn.*), not intra-module packaging. This ADR layers on top of it.
Considered Options
Section titled “Considered Options”Option 1: Status quo (hybrid, no enforcement)
Section titled “Option 1: Status quo (hybrid, no enforcement)”- Description: keep the layer-first/domain-first mix; rely on convention.
- Pros: no work.
- Cons: no boundary; coupling and cycles accrete silently; parking packages never resolve; the entity-leak class of bug recurs.
Option 2: Language-level enforcement (package-private / JPMS module-info)
Section titled “Option 2: Language-level enforcement (package-private / JPMS module-info)”- Description: hide internals with
package-private, or declareexportsvia the Java Platform Module System. - Pros: compiler-enforced.
- Cons:
package-privatedoes not nest — it cannot hideaudit.internal.persistencefromaudit.rest. CDI cross-package proxying and JAX-RS resources require public bean types, so package-private cannot hide beans that are injected or scanned. JPMS is heavyweight and disruptive to the Quarkus build. Language access cannot express ” module-internal across sub-packages.”
Option 3: Package-placement seam + ArchUnit enforcement (chosen)
Section titled “Option 3: Package-placement seam + ArchUnit enforcement (chosen)”- Description: define module visibility by package placement, enforced by an ArchUnit allow-list test. Types are
publicas CDI/JAX-RS require; ArchUnit — not the compiler — forbids cross-module access to non-seam packages. - Pros: works with CDI/JAX-RS; fits the repo’s existing ArchUnit usage; fail-safe default (anything not seam is hidden); one rule scales to all modules via a
MODULESlist. - Cons: enforcement is a test, not the compiler (a violation fails the build in CI, but the IDE won’t stop you typing it); requires discipline to keep the
MODULESlist current.
Decision Outcome
Section titled “Decision Outcome”Chosen option: Option 3. Adopt a repo-wide module seam pattern, enforced by ArchUnit, with audit as the pilot module. Each top-level business package under org.bdinetwork.asr is a module with four package zones:
- Seam — the module root package plus whitelisted seam sub-packages
dto,exceptions, andentity( Amendment 1). The only surface other in-process modules may reach. Cross-module communication goes through the root * service interface* (+ any published events). Callers inject the interface, never the impl. entity/— the module’s published persistence types (Amendment 1, see below): JPA entities that other modules may map@ManyToOneassociations to. Part of the seam; created lazily, only when a cross-module association warrants it.rest/— the module’s HTTP boundary (JAX-RS resource + its wire DTOs/mappers). A top-level sibling ofinternal, not part of the seam. Externally significant and visible at a glance, but not injectable by sibling modules.internal/— hidden implementation: service impls, application services,internal.persistence(JPA entities + repositories), and any further hidden packages added freely underinternal...
Deviation from the source spec (deliberate)
Section titled “Deviation from the source spec (deliberate)”The source pattern places the REST resource under internal.rest. We instead make rest/ a top-level third zone, a sibling of internal. Rationale: a module’s HTTP surface is externally significant — knowing a module has an HTTP boundary, and what it is, matters when reading the module — so it should be visible at the top rather than buried in internal. This is safe because the ArchUnit rule is an allow-list (seam = root + dto + exceptions); anything not seam — including rest/ — is already forbidden to other modules. So rest/ and internal/ have identical cross-module enforcement; the split is purely human-facing signalling of “this is the module’s published HTTP surface.” A resource freely calls its own module’s internal (same-module access is unrestricted).
Rules of the pattern
Section titled “Rules of the pattern”- Seam = root +
dto+exceptions+entity. Nothing else is reachable from other modules. Createdto/exceptions/entitylazily — only when a seam type needs them. dto/holds the service interface’s own types, and (per the existingArchitectureTest.dtos_should_be_records) each must be arecord. Enums and other non-record seam value types go at the root, notdto/.- HTTP-only wire types live under
rest/(e.g.rest/dto), never the seamdto/. - Service impl is
publicand lives ininternal. ArchUnit hides it; callers inject the root interface. internalis a subtree. Add hidden packages underinternal.*freely (fail-safe). Never create hidden packages as seam siblings other thanrest/.model/repositoryparking packages dissolve into each module’sinternal.persistenceas modules migrate.- Tests: same-module tests may touch
internal. Cross-module/e2e tests go through HTTP (RestAssured), not Java imports. Boundary rules exclude tests (ImportOption.DoNotIncludeTests).
Consequences
Section titled “Consequences”- Positive: explicit, enforced module boundaries; no accidental cross-module injection; persistence stays inside its module; the entity-leak bug class is designed out (seam exposes only seam types); one ArchUnit rule scales across modules; a clear migration target for the parking packages.
- Negative: enforcement is a CI test, not the compiler; single-impl services carry interface/impl ceremony; migrating each module is real work; the
MODULESlist must be maintained. - Neutral: types remain
public(CDI/JAX-RS demand it) — visibility is by package, not access modifier. Within a module, a JAX-RS resource may consume a JPA entity frominternal.persistence(same-module, allowed).
Implementation Plan
Section titled “Implementation Plan”- Phase 1 — Audit pilot (COMP-146). Restructure
org.bdinetwork.asr.auditto:audit/AuditRecorder (seam: interface)Action (seam: enum — replaces the leaked AuditRecord.Action)rest/AuditRecordResourcedto/AuditRecordResp (+ from(entity) mapper)internal/AuditRecorderImplAuditRecordQueryServiceAuditPayloads (payload → JsonNode serializer, TDR-0005)persistence/AuditRecord (JPA entity, uses seam Action)AuditRecordRepository``` `AuditRecordQueryService` returns the entity; the resource maps to `AuditRecordResp` (with an in-code comment recording the choice and a reminder to introduce an application-level projection when the read side gains complexity). Add `package-info.java` (`@NullMarked`) to each new package. - Phase 2 — Enforcement. Add
ModuleBoundaryTest(@AnalyzeClasses(packages="org.bdinetwork.asr", DoNotIncludeTests)) withonlySeamReachableAcrossModules( allow-list: root +dto+exceptions) andmodulesAreCycleFree.MODULES = { "org.bdinetwork.asr.audit" }to start. Drop the source spec’s buggyinternalsAreModulePrivaterule (subsumed by the allow-list). - Phase 3 — Roll out. Migrate
document,verification,onboardingopportunistically as they are touched, adding each toMODULES; dissolvemodel/repositoryper module intointernal.persistence.
Amendment 1 (2026-07-06): published-entity zone
Section titled “Amendment 1 (2026-07-06): published-entity zone”Context
Section titled “Context”The COMP-146 severing slice (#154) applied the original pattern strictly: LegalEntityIdentifier’s @ManyToOne OnboardingRequest was replaced by a bare UUID onboardingRequestId so nothing outside the onboarding module could reference its hidden aggregate. Result on the ground: the schema’s onboarding_request_id column has no foreign-key constraint (the baseline deliberately created it as “a bare id, not an FK”), so the link now has neither object navigation nor database referential integrity — it is an orphanable stamp. And the coupling did not disappear: EvidenceRepository still traverses legalEntityIdentifier.onboardingRequestId in JPQL, just untyped.
This over-shoots the goal. These modules are one modulith sharing one database; they are not microservice candidates whose persistence must be separable. Sacrificing referential integrity for import-level isolation is the wrong trade. What the original pattern lacked is a way for a module to keep its entities at home while letting sibling modules hold real, FK-backed @ManyToOne associations to a chosen few of them — without reopening the whole internal.persistence subtree or regressing to a global model basket.
Decision
Section titled “Decision”Add a fourth zone, entity/ — the module’s published persistence types — as a whitelisted seam sub-package alongside dto and exceptions (one entry in ModuleBoundaryTest.SEAM_SUBPACKAGES). This is the modulith equivalent of Spring Modulith’s named interfaces: an explicit, opt-in widening of the seam, per type.
Rules of the zone:
- Entity class only, never its repository. The repository stays in
internal.persistence. Sibling modules may hold references to a published entity —@ManyToOnefields, method signatures, lazy navigation (reads) — but every write to it and every query for it goes through the owning module’s seam service. Publishing an entity publishes a type, not a data-access path. - A published entity has no outbound module references. No association, field, or signature on a published entity may point at another module’s types (in particular: no inverse
@OneToManyback across the boundary). Associations point into theentityzone, never out of it. This keeps the cross-module FK graph directed and keeps the owning module ignorant of its dependents. - Every cross-module
@ManyToOneis backed by a real Liquibase FK constraint with explicitly decided delete semantics. Lifecycle claims (“evidence outlives onboarding”) must be expressed as anON DELETEchoice, not as absence of a constraint. Under the repo’s soft-delete convention (is_deleted),RESTRICTis the default and costs nothing. - Two dependency graphs, each cycle-free, judged separately. The data graph (dependencies on
M.entity..) and the behavior graph (dependencies on module roots,dto,exceptions) are stratified: a module may be upstream in one and downstream in the other without that counting as a cycle. Example this amendment legalises: evidence-side entities referenceonboarding.entity.OnboardingRequest(data), while onboarding services query evidence finders ( behavior). The Phase-2 cycle rule, when it lands, checks each graph independently; a cycle within either graph remains forbidden.
What this amendment does not relax:
- Entities do not move to the module root — the root stays the behavior seam (service interface, seam enums/value types). Mixing entities into it would blur which surface a caller is using.
internal.persistenceremains the default home for entities. Publication is the exception and needs a cross-module association to justify it; anentity/package with no external dependents should be folded back intointernal.persistence.- The bare-UUID + FK form stays available for links that are genuinely provenance-only (no navigation need) — but it must carry the FK constraint; rule 3 applies either way.
Consequences
Section titled “Consequences”- Positive: cross-module many-to-one relations regain type safety, JPQL navigation, and DB referential integrity; entity ownership stays visible in the package tree; publication is explicit and reviewable (a type moving into
entity/is a boundary decision, seen in the diff); the enforcement mechanism is unchanged — same allow-list, one more sub-package. - Negative: the seam is wider — a published entity’s whole public surface (fields, mutators) is reachable, so write-discipline (rule 1) rests on review plus a possible later ArchUnit rule against calling published-entity mutators cross-module; two-graph cycle checking is subtler than one global slice.
- Neutral: JPA cascades across the boundary are effectively excluded by rule 2 (cascades follow outbound associations, which published entities don’t have).
Compliance and Validation
Section titled “Compliance and Validation”- Security review completed
- Performance impact assessed
- Integration impact evaluated
- Documentation updated
- Stakeholder approval obtained
Related Documents
Section titled “Related Documents”- ADR-00009: Layered source code structure — orthogonal (tenancy namespaces, not intra-module layout)
- ADR-00021: Documents behind an ObjectStorageProvider seam — related “seam” vocabulary
- TDR-0005: Audit payload serialization —
AuditPayloads, referenced by the pilot - GitHub issue #148 (admin audit-records read endpoint) and epic COMP-146 (improve audit logging)
Status History
Section titled “Status History”| Date | Status | Notes |
|---|---|---|
| 2026-07-02 | Proposed | Initial proposal — audit as pilot module |
| 2026-07-02 | Proposed | Onboarding enforced alongside the audit pilot (PRD #151): aggregate hidden behind the seam, boundary rule strengthened to dependency-checking, analyze scope widened to all production roots. (Briefly marked Accepted — premature, still on the PR branch.) |
| 2026-07-06 | Proposed | Amendment 1: entity/ published-entity zone — opt-in seam widening for cross-module @ManyToOne with mandatory DB FK; stratified data/behavior cycle graphs |
| 2026-07-06 | Proposed | Phase 3 roll-out (COMP-146 #160–#166): onboarding and evidence migrated to the seam pattern; Actor promoted to org.bdinetwork.asr.common; behavior-graph cycle rule enforced, data-graph rule deferred until model dissolves (GitHub #159); TDR-0006 |
In samenwerking met




