[ADR-0036] Call-Site Values Influencing Nested Composition¶
Status: Accepted
Date: 2026-08-08
Decision Makers: Nick Cipollina, Claude (design review)
Naming note: this ADR keeps the identifier ADR-0036 and its original filename (0036-parameterized-composition-profile-selection.md) for link-stability. Its title and Context were revised, before any design work started, to avoid presupposing the eventual mechanism — the actual gap was stated solution-neutrally, then a deep-design pass (below) evaluated four genuinely different mechanisms before this ADR settled on one. Treat "parameterized... profile selection" in the filename as a historical label only, not a full description of the accepted shape — the accepted shape is narrower and more specific than that filename suggests (a typed configuration object paired with a profile, not a profile constructed directly from an argument list); see Decision Outcome.
Terminology — two distinct concepts, not one. This ADR introduces profile configuration arguments, and deliberately does not reuse inline values to describe them — they are different concepts governed by different code paths, and conflating the terms in documentation or error messages would make both harder to reason about:
- Inline values (existing, ADR-0022) —
[Compose(42, "widget")]'s constructor arguments, bound positionally to the test method's own parameters, partially or fully replacing composition for that row. - Profile configuration arguments (new, this ADR) — bound positionally to a
TConfigtype's constructor, used only to construct the profile that then configures theComposerfor the whole test method; never seen by, or bound to, the test method's own parameters at all.
Context¶
RESEARCH-0002 — a pre-migration capability survey of ncipollina/trivia-platform's AutoFixture-based test kit, run using ADR-0029's rubric — surfaced one finding with no clean answer in Compono's current model, stated at the level the gap actually exists, not at the level of any one candidate fix:
Compono currently has no clean mechanism for compile-time-constant values supplied at a test call site to influence nested composition configuration for that specific test invocation.
trivia-platform's ~16 custom AutoDataAttribute subclasses are the real evidence for this — most take runtime constructor arguments that change what the underlying fixture customization actually produces somewhere inside the composed graph, not just which top-level type gets composed. For example (real call-site shapes, not invented):
PersistenceAutoData(repositoryName)— ~45 call sites, each supplying a different repository name that the attribute's customization logic switches on to configure a different DynamoDB table/persistence setup.AnnouncementsAutoData(validConfig, gameOverEnabled, audienceEnabled, audienceItemEnabled, startOffsetDays, endOffsetDays, messageLocale, defaultLocale)— 8 constructor parameters, 18 call sites, each a distinct boolean/locale combination driving whichAnnouncementsOptionsgets built.HandlerAutoData(requestType, aplSupported, locale, ...)/InterceptorAutoData(...)/PresenterAutoData(...)— hundreds of call sites across the Alexa-handler test suites, each configuring the composedIHandlerInput/request shape differently per test.InfraStackAutoData(region, account).
cosmere-tracker's Milestone 7 dogfooding pass (RESEARCH-0001) never surfaced this pattern — its custom attributes took no meaningful runtime arguments, so this is new evidence, not a recurrence of an already-decided question.
Compono's [Compose<TProfile>] (ADR-0022) selects a fixed, compile-time profile type — TProfile is a generic type parameter, not a runtime value, and ICompositionProfile.Configure(CompositionBuilder) takes no arguments of its own. [Compose(42, "widget")]'s inline-value binding binds test method parameters positionally (per the migration guide's "Migrate [AutoData] and [InlineAutoData]" section); it does not thread a literal into configuration logic that runs somewhere inside the composed graph. There is today no documented way for a value known at the test call site to reach a Register/.For() decision made deeper in composition — every such decision is either fully generic (the same for every caller) or committed to one hard-coded configuration.
What this gap is not. Two adjacent capabilities that might look related are already solved and are explicitly out of scope for whatever closes this gap:
- Requested type + resolution-site name.
CompositionProviderRequest.Namealready lets a customICompositionValueProvidermatch on the requesting parameter/member's own name (docs/concepts/providers.md) — this is how, e.g.,trivia-platform'sSlotSpecimenBuilder/ProductSpecimenBuilder-shaped patterns already have a clean Compono answer per RESEARCH-0002's Finding 2. Nothing about this ADR should re-solve that with a second request-descriptor abstraction. - Fixed member-specific override.
.For<T>().Member(...)already covers "this one member of this one type always gets this value/rule." - The actual gap is a third, distinct case neither of the above reaches: a compile-time-constant value supplied at a specific test's call site needs to influence a composition decision made for that test invocation only — not a global provider rule, not a fixed member override, but a per-invocation input to otherwise-static configuration logic.
Decision Drivers¶
docs/manifesto.md's explicit non-goal of AutoFixture feature parity — this finding still needs to survive the same "is this a real gap or an acceptable Compono-native alternative" question every other finding in RESEARCH-0002 was put through, not be assumed onto the roadmap because AutoFixture happens to support it.- ADR-0029's evidence-driven restraint: a roadmap-candidate finding gets a
ProposedADR stating the problem only — the actual API design belongs to a later deep-design pass, not to this ADR. - ADR-0001's no-reflection-by-default posture and this repo's explicit-over-implicit bias — any eventual solution has to survive these, not just be convenient.
- ADR-0017's immutable-builder model — a profile's
Configuremethod runs once, declaratively, before composition; whatever closes this gap can't require mutating an already-builtComposermid-test. - The evidence is high-frequency and structurally costly, not marginal: per RESEARCH-0002's Finding 1, none of Compono's current mechanisms — writing a distinct profile per configuration variant, or falling back to inline
Composer.Create(builder => ...)in each affected test — let a call site keep the concise, declarative attribute-based idiom ([Compose<TProfile>]on the method, real composed values in the signature) without substantial duplication or hand-written boilerplate once the number of real variants grows past a couple. That cost, not any specific workaround's mechanics, is the actual evidence.
The reframing finding¶
Before evaluating mechanisms, the deep-design pass established a fact that changes the shape of this entire ADR: core Compono already solves the underlying problem. CompositionBuilder.AddProfile(ICompositionProfile profile) (ADR-0018) already exists, and its own XML doc already states its purpose: "for a profile that needs constructor arguments or is otherwise not default-constructible." Programmatically, Composer.Create(b => b.AddProfile(new PersistenceTestProfile(repositoryName))) already works today, with no new code. The gap this ADR closes is entirely one layer up: Compono.XunitV3's [Compose<TProfile>] only supports TProfile : ICompositionProfile, new() — there is no attribute-level path from a compile-time-constant literal to a non-default-constructed profile instance. This means the fix belongs entirely in Compono.XunitV3 — core Compono needs zero new capability. See "Considered Options" below for why this rules out treating the underlying idea as generally useful outside xUnit attributes: it already is, today, via AddProfile(ICompositionProfile).
Considered Options¶
Four genuinely different mechanisms were generated and compared, per design-decisions.md's deep-dive requirement (not a strawman plus a preferred option):
1. Attribute arguments bind directly to TProfile's own constructor¶
[Compose<TProfile>(args...)], reflection-matching args against TProfile's constructor directly — no separate config type.
Rejected — collides with shipped API, not on merit. ComposeAttribute<TProfile>(params object?[] inlineValues) already ships, and its constructor arguments already mean "bind to the test method's leading parameters" (ADR-0022). Reusing that same argument slot to instead mean "construct TProfile with these" is a silent, ambiguous, breaking redefinition of shipped behavior — [Compose<TProfile>(42, "widget")] today binds 42/"widget" to test parameters; this option cannot reuse that syntax without breaking it.
2. Ambient scenario/invocation values, resolved via ICompositionContext¶
Configure(CompositionBuilder)'s signature stays untouched; a new per-row value bag is attached to CompositionRow, and a factory registered inside Configure pulls a value from it at resolve time (context.ResolveScenarioValue<T>("name")), rather than at profile-construction time.
Rejected — no evidence justifies the size of this option. It is the most general shape (it would also cover values varying per row, not just per method), but nothing in RESEARCH-0002's evidence needs per-row variation — every real trivia-platform call site is one attribute instance, fixed arguments, applied once per method. It scores worst on API clarity/discoverability: a profile's scenario dependencies aren't visible in its Configure signature at all, only discoverable by reading every factory body inside it. It is also by far the largest new surface — a new context API, a new resolution-order interaction, new diagnostics naming, and new determinism/seed interaction to design from scratch, none of which RESEARCH-0002's evidence justifies today. Per ADR-0029's evidence-driven restraint, this is named and shelved, not designed further, until a real per-row-varying call site actually surfaces.
3. Source-generated specialization per call site¶
The generator recognizes [Compose<TProfile>(literalArgs)] and emits a closed, literal-baked construction path per call site — zero runtime dispatch of the arguments at all.
Rejected — disproportionate to what it would save. Profile construction already happens once per test method (cached across every row that method produces, per ADR-0022's Caching section), not once per composed object — nowhere near the hot path ADR-0001's no-reflection rule actually targets (repeated per-object construction reflection). Buying that already-cheap, already-bounded cost out entirely would require real new generator complexity (understanding attribute-literal semantics, routing them into type construction, new snapshot-test surface) for a marginal runtime saving.
4. A typed configuration object paired with the profile (chosen)¶
A new, distinct attribute — ComposeAttribute<TProfile, TConfig> — binds profile configuration arguments positionally to TConfig's constructor (reusing ADR-0022's existing inline-value positional-binding validation, retargeted rather than reinvented), constructs TConfig, then constructs TProfile from that TConfig, then hands the fully-built instance to the already-existing, unchanged AddProfile(ICompositionProfile). See Decision Outcome for the full shape.
Chosen — smallest true addition to the system (one new attribute type in Compono.XunitV3, zero core changes), reuses proven binding-validation code instead of inventing new logic, and its one real cost (losing [Compose<TProfile>]'s compile-time new() enforcement) is a narrow, nameable tradeoff rather than a structural one. Full evaluation below.
Decision Outcome¶
Chosen option: 4 — a typed configuration object paired with the profile, implemented entirely in Compono.XunitV3, with zero changes to core Compono (ICompositionProfile, AddProfile<TProfile>(), AddProfile(ICompositionProfile), ComposeAttribute, ComposeAttribute<TProfile>, and the existing inline-value binding algorithm are all unchanged).
Shape¶
public sealed record PersistenceTestConfig(RepositoryKind Repository);
public sealed class PersistenceTestProfile : ICompositionProfile
{
private readonly PersistenceTestConfig _config;
public PersistenceTestProfile(PersistenceTestConfig config) => _config = config;
public void Configure(CompositionBuilder builder) =>
builder.Register<IPlayerRepository>(_ => new PlayerRepository(_config.Repository));
}
[Theory]
[Compose<PersistenceTestProfile, PersistenceTestConfig>(RepositoryKind.Player)]
public void Repository_Works(PlayerRepository sut) { }
A new attribute type, distinct from (not a subclass sharing a constructor-argument slot with) the existing ComposeAttribute<TProfile>:
namespace Compono.XunitV3;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class ComposeAttribute<TProfile, TConfig> : ComposeAttribute
where TProfile : ICompositionProfile
{
// Base gets zero inline values - this attribute form composes every
// test-method parameter in full; profile configuration arguments are
// a completely separate binding target (see Terminology, above).
public ComposeAttribute(params object?[] profileConfigurationArguments) : base()
{
// stored for use by the cached construction delegates described below
}
}
TProfile's new() constraint is dropped (it doesn't apply to this form — see "What this form deliberately gives up," below); TConfig is unconstrained beyond being a type profile configuration arguments can bind to.
Design principle: profile configuration arguments must not encourage stringly typed configuration¶
params object?[] is a binding mechanism, forced by C#'s attribute-argument-must-be-a-compile-time-constant rule — it is not a license to design TConfig types around loosely-typed primitives. Documentation, samples, and this ADR's own examples use the strongest meaningful attribute-legal C# type available for each value:
- A value that represents a finite, named choice → an
enum(RepositoryKind.Player, not"Player"or"PlayerRepository"). - A value that represents a CLR type →
typeof(...)(typeof(IntentRequest), not a type's string name). - A value that's naturally boolean, numeric, or a genuinely free-form string (a locale tag, say) → the corresponding attribute-legal type directly, with no artificial enum/type wrapper forced onto it.
TConfig itself should be a record (per coding-standards.md's "DTOs... must be immutable" rule) whose constructor parameters are named and typed to carry real domain meaning — the same discipline any other strongly-typed configuration object in this codebase already follows, not a special case for this feature.
Constructor contracts — deliberately narrow, deterministic, no "best match"¶
Per the explicit requirement that this stay narrow and predictable rather than reintroducing AutoFixture-style implicit resolution:
TConfigmust have exactly one public constructor. Zero or more than one is a binding-plan-cache-time failure (see Diagnostics below) — never a "pick the best/greediest one" heuristic. Profile configuration arguments bind to that one constructor's parameters positionally, using the identical validation ADR-0022 already built for inline values (count check,Nullable.GetUnderlyingType-unwrap-before-assignability check, clear per-parameter failure messages) — retargeted atTConfig's constructor instead of the test method's parameters, not reimplemented.TProfilemust have exactly one public constructor accepting exactly one parameter of typeTConfig. Not "some constructor that could accept aTConfig," not the greediest overload — an exact, single-parameter, exact-type match. Zero or more than one qualifying constructor is a binding-plan-cache-time failure, same category asTConfig's check.- No "best constructor match" algorithm exists anywhere in this design, for either type. Ambiguity is always a hard, named failure, never a resolved-by-guessing outcome.
Diagnostics¶
All three new checks are pre-composition, computed once (per attribute instance, at first GetData call), and cached — the same place and timing ADR-0022's existing signature-validation checks already run, never re-checked per row:
| Failure | When | Mechanism |
|---|---|---|
TConfig has zero or >1 public constructors | Binding-plan-cache construction | Plain-message CompositionException, naming TConfig and the exact-one-constructor rule |
TProfile has no (or >1) public constructor with exactly one TConfig-typed parameter | Binding-plan-cache construction | Plain-message CompositionException, naming TProfile, TConfig, and the exact-shape rule |
Profile configuration argument count/type/nullability mismatch against TConfig's constructor | Binding-plan-cache construction | Same validation and message shape as today's inline-value mismatch diagnostics (ADR-0022), retargeted at TConfig's parameters |
No new exception type — every case reuses the existing CompositionException convention, consistent with "prefer existing structured diagnostics" (coding-standards.md).
Reflection is bounded and cached, never on the hot path¶
Building the TConfig constructor invoker and the TProfile(TConfig) constructor invoker each happens exactly once per attribute instance, at binding-plan-cache-construction time — the identical close-once-cache-a-delegate shape ADR-0022 already uses for MakeGenericMethod/Delegate.CreateDelegate in "Runtime-Typed CompositionRow Invocation." Every subsequent GetData call for that attribute instance reuses the cached delegates; nothing reflective runs on the per-row composition path, consistent with ADR-0001's no-reflection-on-the-hot-path rule.
What this form deliberately gives up¶
[Compose<TProfile>]'s compile-time new() enforcement does not carry over. Today, [Compose<NotAProfile>] for a type without a public parameterless constructor is a compile error — nothing left to validate at runtime. ComposeAttribute<TProfile, TConfig> cannot offer that: "does TProfile have a constructor accepting exactly one TConfig" is not expressible as a C# generic constraint, so it becomes a deterministic runtime check instead (see Diagnostics above) — still computed once, cached, and failing clearly before any test executes, but a real, honest regression from a compile error to a pre-composition runtime error. This is stated explicitly here per the requirement that it not be glossed over: it is an accepted cost of the chosen shape, not an oversight.
Scope: Compono.XunitV3 only, zero core changes¶
ICompositionProfile, CompositionBuilder.AddProfile<TProfile>(), CompositionBuilder.AddProfile(ICompositionProfile), ComposeAttribute, ComposeAttribute<TProfile>, and the existing inline-value binding algorithm are all unchanged by this ADR. The new ComposeAttribute<TProfile, TConfig> type is additive, in Compono.XunitV3 only. This directly answers "does the underlying feature belong in core or only Compono.XunitV3": the underlying capability (building a profile from call-site-known values) is already a core capability, reachable today from any C# call site via AddProfile(new Profile(...)) — what's missing is specifically an attribute-to-instance bridge, which is inherently a problem of attribute-based test-framework integration, not of the composition engine itself. A future NUnit/MSTest integration would face the identical bridging problem and solve it the identical way inside its own package — not a reason to hoist this into core speculatively before a second consumer exists.
Positive Consequences¶
- Closes RESEARCH-0002's Finding 1 with the smallest true addition to the system evaluated — one new
Compono.XunitV3attribute type, zero core changes. - Reuses ADR-0022's proven positional-binding validation rather than inventing a second binding algorithm.
- Strong typing at the point that matters most — inside
Configure, a profile author writes ordinary typed C# againstTConfig, neverobject[]unpacking. - The "no stringly typed configuration" principle, and the "inline values" vs. "profile configuration arguments" terminology split, are now first-class, documented parts of the design — not left implicit for a future doc pass to get wrong.
- Existing
[Compose],[Compose<TProfile>], inline-value binding, andAddProfile<T>()are completely unaffected — no migration, no behavior change, for any test that doesn't opt into the new attribute form.
Negative Consequences¶
- Loses
[Compose<TProfile>]'s compile-timenew()enforcement for this form specifically, replaced by a deterministic but runtime check — see "What this form deliberately gives up" above. - Two attribute forms now exist for profile selection (
ComposeAttribute<TProfile>andComposeAttribute<TProfile, TConfig>) instead of one — an accepted, small increase in public-surface area for a capability real evidence demonstrates is needed. - Options 2's more general "any call-site value, including per-row variation" capability remains unbuilt — accepted per ADR-0029's evidence-driven restraint; revisit only if a real per-row-varying call site surfaces.
Pros and Cons of the Options¶
Leave it as-is (not chosen)¶
- Good, because it requires no further Compono work.
- Bad, because it leaves a high-frequency, high-cost gap unaddressed for any real project (not just
trivia-platform) whose tests need a call-site value to shape nested composition.
Option 1 — args bind directly to TProfile's constructor (rejected)¶
- Good, because it needs no separate
TConfigtype. - Bad, because it collides with
ComposeAttribute<TProfile>'s already-shipped inline-value constructor-argument meaning — not resolvable without breaking existing behavior.
Option 2 — ambient scenario values via context (rejected, shelved)¶
- Good, because it's the most general shape, covering per-row variation option 4 doesn't.
- Bad, because nothing in the evidence needs per-row variation, and it's the largest new surface of any option considered, with the weakest API-discoverability story (a profile's dependencies aren't visible in its own signature).
Option 3 — source-generated specialization (rejected)¶
- Good, because it's the most "true to source-gen-first" mechanism, with zero runtime argument dispatch.
- Bad, because profile construction is already a cheap, one-time-per-method cost, not a hot path — the generator complexity this option would add isn't proportionate to what it saves.
Option 4 — typed configuration object paired with the profile (chosen)¶
- Good, because it reuses proven binding-validation code instead of inventing new logic.
- Good, because it requires zero core
Componochanges. - Good, because it keeps strong typing at the
Configureboundary. - Bad, because it loses
[Compose<TProfile>]'s compile-timenew()enforcement, replaced by a deterministic runtime check — an accepted, explicitly-stated cost.
Amendment 1 (2026-08-09): direct ConstructorInfo.Invoke, not a cached delegate¶
"Reflection is bounded and cached, never on the hot path" (above) specified the identical close-once-cache-a-delegate shape ADR-0022 uses for MakeGenericMethod/Delegate.CreateDelegate — building a cached invoker delegate for TConfig's and TProfile's constructors once, at binding-plan-cache-construction time. PLAN-0036's implementation does not do this: ConfigProfileBinder calls ConstructorInfo.Invoke directly (via a small shared Invoke helper that also unwraps TargetInvocationException, per PR #65 review round 3), with no separate delegate-caching layer of its own.
This is a correction to that section's implementation detail, not a reversal of the section's actual guarantee. The guarantee — reflection bounded to once per attribute instance, never on the repeated per-row GetData path — still holds, for a different reason than originally assumed: ComposeAttribute<TProfile, TConfig>.ApplyProfile (the only caller of ConfigProfileBinder's methods) is itself only ever invoked once per attribute instance, from inside the base ComposeAttribute's existing Lazy<Composer>-backed caching (ComposeAttribute.cs's _composer field) — a caching layer this ADR's original design already relied on for the composer as a whole, but didn't originally credit with also bounding the constructor-resolution reflection specifically. RowInvokers' MakeGenericMethod/Delegate.CreateDelegate shape exists for a genuinely different reason: it closes a generic method over a parameter type known only at runtime (a test method's own ParameterInfo.ParameterType, discovered per-parameter across potentially many parameters), which needs a delegate cache to avoid MakeGenericMethod/MethodInfo.Invoke cost repeating per row. TConfig/TProfile need no equivalent: they are already compile-time-closed generic arguments on ComposeAttribute<TProfile, TConfig> itself, so there is no per-runtime- discovered-type generic closure to cache in the first place — a direct ConstructorInfo.Invoke, called once (per the Lazy<Composer> guarantee above), already satisfies the no-reflection-on-the-hot-path requirement without needing the heavier delegate-caching mechanism.
This does not change the ADR's Decision Outcome (Option 4 remains chosen) or any of its stated tradeoffs — it corrects one implementation detail this ADR specified more precisely than turned out necessary, per design-decisions.md's Amendment mechanic for a correction discovered during implementation.
Links¶
- RESEARCH-0002 — Finding 1, the evidence this ADR records
- ADR-0029 — the rubric/classification framework and evidence-driven-restraint rule this ADR follows
- ADR-0022 — governs
[Compose<TProfile>]'s current fixed-type-only selection - ADR-0018 — governs
ICompositionProfile's current no-argumentConfigureshape - ADR-0017 — the immutable-builder constraint a future solution must respect
- ADR-0001 — the no-reflection-by-default constraint a future solution must respect
ncipollina/trivia-platform— the repo whose real call sites motivate this ADR; not part of this monorepo