Skip to content
Deze documentatie wordt actief uitgebreid — kom regelmatig terug.

ADR-00022: Module seam pattern — in-process seam vs internal, with a REST-boundary zone

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 bodyStatusLast update
CTN Project TeamProposed2026-07-06
CTN Technical Advisory BoardPending
CTN Steering CommitteePending
BDI Conformance TeamPending
BDI Framework TeamPending

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.

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.

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 declare exports via the Java Platform Module System.
  • Pros: compiler-enforced.
  • Cons: package-private does not nest — it cannot hide audit.internal.persistence from audit.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 public as 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 MODULES list.
  • 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 MODULES list current.

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:

  1. Seam — the module root package plus whitelisted seam sub-packages dto, exceptions, and entity ( 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.
  2. entity/ — the module’s published persistence types (Amendment 1, see below): JPA entities that other modules may map @ManyToOne associations to. Part of the seam; created lazily, only when a cross-module association warrants it.
  3. rest/ — the module’s HTTP boundary (JAX-RS resource + its wire DTOs/mappers). A top-level sibling of internal, not part of the seam. Externally significant and visible at a glance, but not injectable by sibling modules.
  4. internal/ — hidden implementation: service impls, application services, internal.persistence (JPA entities + repositories), and any further hidden packages added freely under internal...

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).

  • Seam = root + dto + exceptions + entity. Nothing else is reachable from other modules. Create dto/ exceptions/entity lazily — only when a seam type needs them.
  • dto/ holds the service interface’s own types, and (per the existing ArchitectureTest.dtos_should_be_records) each must be a record. Enums and other non-record seam value types go at the root, not dto/.
  • HTTP-only wire types live under rest/ (e.g. rest/dto), never the seam dto/.
  • Service impl is public and lives in internal. ArchUnit hides it; callers inject the root interface.
  • internal is a subtree. Add hidden packages under internal.* freely (fail-safe). Never create hidden packages as seam siblings other than rest/.
  • model/repository parking packages dissolve into each module’s internal.persistence as 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).
  • 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 MODULES list 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 from internal.persistence (same-module, allowed).
  1. Phase 1 — Audit pilot (COMP-146). Restructure org.bdinetwork.asr.audit to:
    audit/
    AuditRecorder (seam: interface)
    Action (seam: enum — replaces the leaked AuditRecord.Action)
    rest/
    AuditRecordResource
    dto/AuditRecordResp (+ from(entity) mapper)
    internal/
    AuditRecorderImpl
    AuditRecordQueryService
    AuditPayloads (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.
  2. Phase 2 — Enforcement. Add ModuleBoundaryTest ( @AnalyzeClasses(packages="org.bdinetwork.asr", DoNotIncludeTests)) with onlySeamReachableAcrossModules ( allow-list: root + dto + exceptions) and modulesAreCycleFree. MODULES = { "org.bdinetwork.asr.audit" } to start. Drop the source spec’s buggy internalsAreModulePrivate rule (subsumed by the allow-list).
  3. Phase 3 — Roll out. Migrate document, verification, onboarding opportunistically as they are touched, adding each to MODULES; dissolve model/repository per module into internal.persistence.

Amendment 1 (2026-07-06): published-entity zone

Section titled “Amendment 1 (2026-07-06): published-entity zone”

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.

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:

  1. Entity class only, never its repository. The repository stays in internal.persistence. Sibling modules may hold references to a published entity — @ManyToOne fields, 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.
  2. 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 @OneToMany back across the boundary). Associations point into the entity zone, never out of it. This keeps the cross-module FK graph directed and keeps the owning module ignorant of its dependents.
  3. Every cross-module @ManyToOne is backed by a real Liquibase FK constraint with explicitly decided delete semantics. Lifecycle claims (“evidence outlives onboarding”) must be expressed as an ON DELETE choice, not as absence of a constraint. Under the repo’s soft-delete convention (is_deleted), RESTRICT is the default and costs nothing.
  4. 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 reference onboarding.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.persistence remains the default home for entities. Publication is the exception and needs a cross-module association to justify it; an entity/ package with no external dependents should be folded back into internal.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.
  • 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).
  • Security review completed
  • Performance impact assessed
  • Integration impact evaluated
  • Documentation updated
  • Stakeholder approval obtained
DateStatusNotes
2026-07-02ProposedInitial proposal — audit as pilot module
2026-07-02ProposedOnboarding 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-06ProposedAmendment 1: entity/ published-entity zone — opt-in seam widening for cross-module @ManyToOne with mandatory DB FK; stratified data/behavior cycle graphs
2026-07-06ProposedPhase 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

Connected Trade NetworkConclusionData in LogisticsContargoInland Terminals GroupVan Berkel