Skip to content

ADR-0003: Byte-identical twin code lives in legatium-common, inlined by Shade

  • Status: accepted
  • Date: 2026-09-03
  • Context: The sibling project limesium started with two deliberately duplicated twins, then learned (its ADR-0003 of 2026-08-30, with two amendments) that every shared-layer change became a synchronized multi-file port in both directions, and extracted the byte-identical set into an internal module inlined by Shade - under the constraint that consumers keep adding exactly ONE artifact. Legatium is built as limesium's twin project and inherits that lesson from day one instead of re-learning it.

Decision

The byte-identical shared code lives in the legatium-common module; each twin inlines it into its own jar with the Maven Shade plugin; legatium-common itself is never published.

  • What is shared: Traceparent (with unit, conformance-fixture and Jazzer fuzz tests), NanoTimeSource, CorrelationIdGenerator (with the counting default, ADR-0004), reportQuietly/failOpen, Mdc.kt (MdcKeys/TraceMdcKeys/MdcScope), HeaderLogProperties (selection semantics, with unit test and fuzz target) and HeaderValueMasker (the injectable masking, with the fingerprint default), BodyReadState/decodeTruncated, Timeouts (the classification that makes adapter_outcome=timeout mean the same thing on both stacks), and - since the amendments below - the ClientLogField enum with its builder extensions and the ClientLoggingProperties binding. Package: eu.inqudium.legatium.common.
  • What deliberately stays duplicated: everything whose twin copies genuinely differ - the metrics (per-stack outcome vocabulary and meter descriptions), the emitters, exchanges, interceptor/filter, and BoundedBodyCapture (two different concurrency designs: volatile single-writer on the blocking stack, lock and freeze on the reactive one). For those the accepted cost is a conscious port in both directions; the lockstep tests (each twin's TwinContractTest for the message text and the stack's own outcome vocabulary; SharedContractTest, ClientLogFieldTest and ClientLoggingReferenceConfigTest in legatium-common for the shared literals) catch named contract drift, not behavioural drift.
  • Shading: an artifactSet restricted to eu.inqudium: legatium-common, NO relocation (relocating rewrites bytecode but not Kotlin metadata), keepDependenciesWithProvidedScope=false so the dependency-reduced POM drops the dependency entirely, and the module's META-INF/maven filtered out of the shaded jar. spring-boot-starter-parent pre-configures an unnamed uber-jar shade execution; it is unbound (phase=none) so declaring the plugin does not swallow the compile classpath.
  • Visibility: the twins compile with -Xfriend-paths (own output dir, common's classes dir AND jar - the reactor resolves the dependency as a directory before packaging and as a jar afterwards), so the shared classes stay internal. Consequence: a twin builds only through the reactor (from the root, or with -am) - a lone -pl <twin> build resolves the dependency from the local repository, which is not a friend path, and fails with "internal in file".
  • Not published: maven.deploy.skip=true plus skipPublishing=true for the Central Portal bundle. The published twin POMs mention no legatium-common.
  • Documentation: each twin's Dokka run includes the common sources as an additional source root - the API reference documents what the shaded jar actually contains, and cross-module KDoc links resolve under failOnWarning. The Docs workflow installs (not merely verifies) before the per-module Dokka runs, so the dependency resolves.

Consequences

  • A shared-layer change is made ONCE; the both-directions port and its drift risk exist only for the deliberately duplicated remainder.
  • Consumers are unaffected in shape: one artifact, no new transitive dependency, internals stay internal.
  • Both twin jars carry byte-identical copies of the common classes. An application with BOTH twins on the classpath (a servlet host that also uses WebClient - a supported deployment here, unlike limesium's twins) sees benign duplication at equal versions and classpath-order-dependent classes at skewed versions: keep the two versions equal.
  • The common classes carry the eu.inqudium.legatium.common package, distinct from limesium's eu.inqudium.limesium.common, so a host that runs limesium AND legatium (the intended pairing) never sees two copies of one class name.
  • -Xfriend-paths is a -X compiler flag: stable in practice and used widely for test friendship, but not a documented contract; a Kotlin upgrade that changes it surfaces as a loud compile error, never as silent misbehaviour.

Amendment (2026-09-03)

The ClientLogField enum (wire names, per-field type guarantee, the addKeyValue/addKeyValueIfPresent/setCauseIfPresent builder extensions) was listed above as deliberately duplicated, by analogy with limesium, whose twin enums genuinely differ (endpoint_async exists on one stack only). Here the two copies were byte-identical apart from KDoc prose: the field family is one cross-stack contract, and the only stack-specific fact - the reactive cancelled outcome - is a VALUE of adapter_outcome, not a field. The enum now lives in legatium-common, documented stack-neutrally, and ClientLogFieldTest binds the ELK component template against it ONCE there (the template is declared as a test resource of legatium-common); the twins no longer carry the test or the template resource. The ELK template's authority points at the new location.

Amendment (2026-09-03, second)

The same review found ClientLoggingProperties byte-identical apart from KDoc wording (interceptor vs. filter) - the adapter-logging.* namespace is one cross-stack contract by design, key for key and default for default, and unlike limesium there is no stack-only key (variant) to justify two classes. The class now lives in legatium-common (which therefore depends on spring-boot for the @ConfigurationProperties annotation - no autoconfigure, no starter), documented stack-neutrally; both twins' auto-configurations enable the same class, and a host carrying both twins gets one properties bean (Boot derives the bean name from prefix and class). ClientLoggingPropertiesTest and ClientLoggingReferenceConfigTest moved along, so the reference YAML is bound ONCE in legatium-common and the twins declare no shared-docs test resources any more. NOTE - source-facing for hosts that import the class for a custom interceptor/filter bean: the package is eu.inqudium.legatium.common, decided before the first release. The two twins' copies of the test helper MdcAdapterSwap were unused (only legatium-common's MdcScopeTest swaps the adapter) and were deleted; the "copies are cheaper than a test-jar" rule applies to helpers a module actually uses.

Amendment (2026-09-04): not relocated - the consequences, stated

The inlined classes keep their package (eu.inqudium.legatium.common); they are NOT relocated per twin. Relocation would break the one thing the shared layer is for: a host bean of HeaderValueMasker, NanoTimeSource or CorrelationIdGenerator masks or clocks BOTH twins, and a host that imports ClientLoggingProperties for a custom interceptor or filter bean writes one import - with relocation each twin would carry its own type and none of that holds. The price is explicit:

  • Both twins in one application carry the same classes twice, under the same names, and the first jar on the classpath wins. That is safe only while the two versions are byte-identical - hence "keep the two versions equal" in both READMEs; a version skew between the twins is a misconfiguration.
  • The JPMS module path is unsupported for a host carrying both twins: two automatic modules exporting the same package are a split-package error and the application does not start. A single twin on the module path is fine. (The build itself runs Surefire with useModulePath=false for the same reason.)

Amendment (2026-09-04, second): the metrics owner and the activation move; a threshold; the 1.0 decision

The architecture review of 2026-09-04 measured the "deliberately duplicated remainder" with stack names neutralised: the two ClientLoggingMetrics classes were 95 % identical (the differences were the outcome list, one tag value and two descriptions - data, not design) and the activation logic 96 % (identical). Both now live in legatium-common: ClientLoggingMetrics parameterised by a ClientStack (the client tag, the outcome vocabulary, the gauge's wording; the per-registry cache is keyed by registry and stack) and ClientActivation built from the properties. The emitters (72 %), the exchanges and the two BoundedBodyCaptures remain duplicated - they differ in design, not in data.

Threshold, so this is a rule rather than a review: a twin-paired file that reaches 90 % line similarity after neutralising the stack names is byte-identical enough to move to legatium-common, parameterised where it must differ. Below that, a copy stays a copy and the both-directions port is the accepted cost.

The inlining stays through 1.0 (review finding 4, decided). Publishing legatium-common as a regular artifact would remove the friend-path build coupling, the duplicate classes and the JPMS split-package exclusion, at the price of one more artifact in every consumer's tree and a public API surface for the shared types. The one-artifact shape and internal shared types are kept for the first release; the documented costs (above) are accepted. The decision is re-opened by the first of: a consumer that needs the module path with both twins, a third twin, or a shared-type API change that would break consumers of both jars.

Amendment (2026-09-05): the proof moves to the consumer's side of the boundary

The architecture review of 2026-09-05 (finding 3) found the evidence for this decision sitting on the wrong side of it. Surefire runs the twins' tests in the test phase against the legatium-common module; Shade inlines the classes and writes the dependency-reduced POM afterwards, in package, and nothing ever loaded the jars a consumer receives. Meanwhile ten of the twelve methods in the two TwinContractTest files pinned literals of types that, since the amendments above, exist only once in legatium-common - a drift "between the twins" they were written against had become impossible.

  • Shared literals are pinned once. SharedContractTest in legatium-common pins the meter names and fallback tag values, the read states, the MDC keys, the outcome vocabulary, the fail-open stages and the request-id sources; the masking fingerprint was already pinned in HeaderValueMaskerTest. Each twin's TwinContractTest keeps the two facts the twin owns: its ClientStack (client tag, pre-registered outcomes) and the message text its emitter renders.
  • The shared owner is tested once. The registration behaviour of ClientLoggingMetrics (fallback registry, gauge collision, guarded updates, tag folding, the no-op composite) moved from both twins' metrics tests into ClientLoggingMetricsTest in legatium-common (ADR-0008).
  • The packaging is verified where it matters. The standalone project consumer-smoke/ (no reactor child, like limesium's benchmarks/) depends on both twins exactly as an application does and starts a Boot context on the installed jars: the inlined common classes must resolve from exactly the two twin jars and from no legatium-common artifact, both auto-configurations must wire up through the jars' own imports files, and one call per client must end in one exchange line against a real local peer. The CI job consumer-smoke installs the reactor, DELETES legatium-common from the local repository and only then builds the consumer - a dependency-reduced POM that still named the unpublished module fails there, not at the first consumer. The both-twins-on-one- classpath case this ADR documents is thereby exercised on every push.