[ADR-0066] Compono.XunitV3.Aot: A Separate Package for xUnit v3 Native AOT Support¶
Status: Accepted
Date: 2026-09-12
Decision Makers: Nick Cipollina (product owner), agent (design dive via /engineering-workflow, following RESEARCH-0032)
Context¶
RESEARCH-0031 found, via a corrected spike, that Compono.XunitV3 cannot be referenced alongside xUnit v3's real, officially-supported Native AOT package family (xunit.v3.aot.mtp-v2 and its .aot-suffixed dependencies) at all — a compile-time CS0433 conflict, not a runtime nuance, caused by Compono.XunitV3's shipped dependency on the reflection-mode xunit.v3.extensibility.core package. xUnit v3 4.0 (2026-08-14) is the version that introduced official Native AOT support; Compono.XunitV3 predates it and was never updated to account for the parallel package family xUnit introduced alongside it.
RESEARCH-0032 investigated whether this can be closed within the existing package, and if not, what the smallest correct architecture is. It found, with direct evidence from xUnit's own source and a working end-to-end proof spike (scratchpad only, discarded, no production code changed), that:
- A single package cannot support both modes —
ComposeAttribute : DataAttributeis permanently bound to oneDataAttributeassembly identity at Compono's own build time, and xUnit's AOT-onlyRegisteredEngineConfig.RegisterTheoryDataRowFactoryAPI doesn't exist in the reflection-mode assembly at all, so no single piece of source compiles against both. - xUnit's own AOT extensibility model (proven directly against its official
AotCsvDataSourcesample and xUnit'sDataAttributeGeneratorsource) is: a near-empty markerDataAttributesubclass, plus compile-time-generated code callingRegisteredEngineConfig.RegisterTheoryDataRowFactorywith a factory closure that supplies theory data rows.DataAttribute.GetData's runtime override is never invoked in this path at all. - A proof spike (hand-written stand-in for generator emission, calling only unmodified, already-shipped core
Componopublic API) published as Native AOT, ran as a native binary, and passed — the real xUnit v3 AOT pipeline discovered a[Theory]+[Compose]-shaped test, invoked the registered factory, and Compono composition executed correctly inside the native process. Compono.Generatorsalready hardcodes each integration package's[Compose]-attribute metadata name as a string constant (ComposeMethodDiscovery.AttributeMetadataName, etc.) — a seventh constant for a new AOT-specific attribute follows an already-established pattern, requires no new assembly reference fromCompono.Generatorsto any xUnit package, and generated source text can callRegisteredEngineConfig.RegisterTheoryDataRowFactoryby fully-qualified name without the generator itself ever referencing the defining assembly (proven by the spike).
Decision Drivers¶
- Native AOT compatibility is an explicit Compono design goal (ADR-0001);
Compono.XunitV3is Compono's oldest, most established, most heavily dogfooded integration package, and cannot currently coexist with xUnit's own now-official Native AOT support at all. - No reflection-based fallback by default (ADR-0001) — the chosen design must not introduce runtime reflection to bridge the two modes.
- The core
Componopackage, andCompono.Generators, must never take on a hard reference to an integration package or a specific test framework's assemblies (references/design-decisions.mdrule 3) — string-metadata-name matching is the established, precedented exception; an actual assembly reference is not. - Prefer the smallest maintainable design — do not attempt inline values,
[Shared], or profile-variant support in the same change as the core mechanism; do not invent a reflection-compatibility shim for the two xUnit package families. - Backward compatibility for existing
Compono.XunitV3consumers is non-negotiable unless a compelling reason exists to break it — product-owner decision (this session): purely additive, no breaking change, no major version bump.
Considered Options¶
- Single
Compono.XunitV3package supporting both modes (via TFM/build-property/conditional dependency selection). - A separate AOT-specific package (
Compono.XunitV3.Aot), referencing only xUnit's.aotpackage family, shipping its ownComposeAttribute, activated byCompono.Generators' extended emission. - Internal reorganization only — a shared, framework-independent composition core with thin reflection-mode and AOT-mode adapters, without necessarily creating a new public package.
- Do not support xUnit Native AOT at all.
Decision Outcome¶
Chosen option: 2, a separate Compono.XunitV3.Aot package (with option 3's internal-reorganization insight folded in as an implementation detail, not a competing package-boundary choice — see below).
Option 1 is not merely riskier than option 2; it is not achievable at all through supported NuGet/MSBuild mechanisms, confirmed by direct evidence (§RESEARCH-0032 §3): the two xUnit package families target identical TFMs (no TFM axis to key a conditional dependency group on), and the compile-time-bound DataAttribute identity plus the AOT-only RegisterTheoryDataRowFactory API make a single compiled ComposeAttribute structurally impossible regardless of packaging cleverness. Option 4 is rejected — Gate A and Gate B both clear cleanly (RESEARCH-0032 §12/§13), and a real, working, end-to-end proof already exists.
Option 3's "shared core, thin adapters" framing is correct and adopted inside option 2's package boundary, not as an alternative to it: core Compono already is that shared, framework-independent core (confirmed unmodified by the proof spike), so no new internal abstraction layer needs inventing — Compono.XunitV3 (reflection-mode ComposeAttribute, runtime GetData) and Compono.XunitV3.Aot (AOT-mode marker ComposeAttribute, generator-emitted registration) are both already "thin adapters" over the same unmodified core, satisfying option 3's actual intent without a redundant new package.
Package name: Compono.XunitV3.Aot (product-owner decision, this session) — mirrors xUnit's own xunit.v3 → xunit.v3.aot naming convention exactly.
Namespace: Compono.XunitV3.Aot, not a reuse of Compono.XunitV3's own namespace — avoids any possibility of a using-directive ambiguity for a consumer whose solution (never a single project) contains both packages.
Phase 1 scope: plain [Compose] only. No inline values, no [Shared], no [Compose<TProfile>]/[Compose<TProfile, TConfig>] in the first release — these require real, additional generator work (reproducing BindingPlan's inline-value validation and [Shared] ordering, and ConfigProfileBinder's profile-construction logic, all as compile-time-generated code) that the proof spike did not attempt and that should not gate shipping the core, proven mechanism. See PLAN-0066 for phasing.
Positive Consequences¶
- Closes a real, present compatibility gap for Compono's most established integration package, with a proven-working mechanism, not a design argument.
- Zero impact on existing
Compono.XunitV3consumers — purely additive, no breaking change. Compono.Generatorsgains one new attribute-metadata-name constant and new emission logic, following an already-established pattern (six existing constants for the other four integration packages' attribute families) — no new project, no new package reference, no new category of coupling.- The generated dispatch is, if anything, more AOT-idiomatic than the existing reflection-mode path: every
Tis known at compile time from the method symbol directly, so generated code can callrow.Resolve<T>(descriptor)with a literal closed generic type — noRowInvokerRegistryruntime lookup needed for this path at all. - Existing
[Theory]+[Compose]test syntax remains unchanged; consumers opt into the AOT integration by changing package/reference imports toCompono.XunitV3.Aot(namespace andusingincluded — only the attribute usage at the test method itself reads identically in source).
Negative Consequences¶
- A second, real package to maintain, version, and document — mitigated by it being purely additive (no coordination burden with
Compono.XunitV3's own release cadence) and by the maintenance-cost factor already being weighed as part of Gate A (RESEARCH-0032 §12), not a standalone veto. - Full
[Compose]parity (inline values,[Shared], profile variants) is deferred, meaningCompono.XunitV3.Aot's first release will be less capable thanCompono.XunitV3's full surface — an explicit, accepted phasing decision (see Decision Drivers), not an oversight. Compono.Generatorsneeds new per-compilation mode-detection logic (whichComposeAttributemetadata name matched already provides this for free, per RESEARCH-0032 §5, but it's still new surface requiring its own determinism/caching verification during implementation).
Pros and Cons of the Options¶
Single package, both modes¶
- Bad, because it is not achievable through any supported NuGet/MSBuild mechanism — confirmed by direct evidence, not merely disfavored on style grounds (RESEARCH-0032 §3).
Separate Compono.XunitV3.Aot package (chosen)¶
- Good, because it mirrors xUnit's own package-split convention exactly — idiomatic, discoverable.
- Good, because it requires zero changes to existing
Compono.XunitV3/coreCompono— proven by the spike. - Good, because
Compono.Generators' extension follows an already-established, low-risk pattern. - Bad, because it's a new package to maintain — accepted, weighed explicitly against real Gate A/B evidence, not a decisive objection on its own.
Internal reorganization only, no new package¶
- Good, because it would minimize package count.
- Bad, because it doesn't actually solve the problem — the
ComposeAttributetype itself still needs two distinct compiled identities regardless of how the logic underneath is factored; "no new package" isn't achievable without abandoning[Theory]+[Compose]'s existing shape (rejected per the design drivers) or breakingCompono.XunitV3's existing consumers (rejected per backward-compatibility driver).
Do not support xUnit Native AOT¶
- Good, because it's zero work.
- Bad, because Gate A and Gate B both clear cleanly, real dogfooding-grade evidence exists (this repo's own most-used integration package), and a working proof already exists — rejecting outright would discard real, already-validated value for no offsetting reason.
Links¶
- RESEARCH-0031 — the broader investigation that first surfaced this gap.
- RESEARCH-0032 — the full evidence base and proof spike this ADR's decision is drawn from.
- ADR-0041 — the generated-module-initializer pattern this ADR's generator emission mirrors.
- ADR-0040 / ADR-0057 — precedent for a framework-specific integration package's own design pass and TFM-floor reasoning.
- PLAN-0066 — the phased execution tracker for this decision.