[ADR-0014] Generator-Emitted Collection Plans Replace the Reflection-Based Dispatch Bridge¶
Status: Accepted
Date: 2026-07-28
Decision Makers: solo
Context¶
ADR-0010's Amendment 2 specified a runtime collection-dispatch mechanism for the five ADR-0013 collection shapes: a non-generic built-in ICompositionProvider that, once it claimed a supported closed collection type, dispatched element/key/value resolution through a cached, strongly-typed generic delegate built once per closed collection type via MethodInfo.MakeGenericMethod + CreateDelegate. That amendment argued this was "categorically different from the arbitrary-user-type reflection fallback ADR-0001 prohibits" — a narrow, fixed, cached exception, not the open-ended reflection fallback ADR-0001 rules out.
A review before any Milestone 2 Phase 2 implementation had been written against that design caught that this reasoning doesn't hold: a fixed, bounded set of reflected shapes is still reflection introduced into Compono's default runtime architecture, which design-decisions.md rule 4 (derived from ADR-0001) doesn't carve an exception for. This ADR retracts the collection-dispatch-bridge design and replaces it with a generator-emitted mechanism instead — the same fix ADR-0001's own Consequences section already named as the correct path for a shape the generator doesn't yet cover, and the one Amendment 2 itself flagged as "the specific, already-identified place to move to" if the reflection bridge ever became unacceptable. It became unacceptable before any code was written against it, so this ADR is that change, made pre-implementation rather than as a later migration once real reflection-based code shipped.
This ADR was originally recorded as an in-place "Amendment 3" appended to ADR-0010 itself. A second PR #11 review correctly flagged that as a violation of this repo's actual documented rule (design-decisions.md: "don't edit [an accepted ADR's] Decision/Rationale/Consequences... write a new ADR") regardless of ADR-0010's own pre-existing Amendments ½, which predate this PR and are themselves the same kind of drift from that rule, not a sanctioned exception to it. This ADR is that correction done properly: its own new, numbered, indexed record, with ADR-0010, ADR-0012, and ADR-0013 each left completely untouched in their Decision/Amendment content and only gaining a Links-section pointer here (metadata, not decision content). ADR-0010's other decisions (the pipeline model, the descriptor shape, diagnostics tracing) are unaffected and remain in force; this ADR does not supersede ADR-0010 as a whole, only the collection-dispatch-bridge sub-decision within it.
Decision Drivers¶
- No reflection in the default runtime architecture (ADR-0001) — a fixed, bounded set of reflected shapes is still reflection, not an exception ADR-0001 grants.
- Native AOT/trimming safety —
MakeGenericMethodover a value-type argument can require runtime code generation unavailable under Native AOT; Milestone 2's collection support shouldn't introduce a new AOT gap even though Native AOT certification itself is an MVP non-goal. - Consistency with the dispatch mechanism ADR-0004 already established for ordinary composable types (
PlanCache<T>) — a closed-generic static field read, not aType-keyed lookup or reflected invocation. - Pipeline precedence must be preserved: explicit values, shared values, registrations, profile rules, semantic providers, and test-double providers must still get first refusal over collection handling (ADR-0013's "collections stay ordinary pipeline requests" decision).
Considered Options¶
- Keep the reflection-based dispatch bridge (ADR-0010 Amendment 2's original design) —
MakeGenericMethod+CreateDelegate, cached per closed collection type, invoked from a non-generic built-inICompositionProvider. - Generator-emitted collection plans, dispatched through a new closed-generic cache (
CollectionPlanCache<T>) read directly insideCompositionContext.ResolveCore<TValue>, mirroringPlanCache<T>'s existing zero-reflection dispatch exactly.
Decision Outcome¶
Chosen: Option 2 — generator-emitted collection plans. Compono.Generators discovers the fixed Milestone 2 collection shapes in the transitive composition graph and emits a strongly-typed collection plan per closed shape, dispatched through a new closed-generic cache — CollectionPlanCache<T> — read directly inside CompositionContext.ResolveCore<TValue>, exactly the same zero-reflection mechanism ADR-0004 already established for PlanCache<T>.
- Discovery.
TransitiveClosureWalker(Milestone 1) is extended to recognize the five ADR-0013 shapes (T[],List<T>,IReadOnlyList<T>,HashSet<T>,Dictionary<TKey, TValue>) wherever they appear as a constructor parameter or required member type in the walked graph — including nested inside another collection (List<List<Address>>), and including the root ofComposer.Create<T>()/[Composable]itself, not only nested members. A recognized collection shape is not walked as an ordinary composable type (no constructor selection is attempted againstList<T>itself); instead its element type (and key type, forDictionary) is fed back into the same eligibility walk, so a composable element type still gets its own ordinary plan, a provider-resolved element type stays a bareResolve<TElement>()call, and a nested collection element type recurses through this same collection-shape check. - Emission. For each distinct closed collection type reached, the generator emits one
file-scopedICompositionPlan<TCollection>implementation (samefile-scoping convention as an ordinary composition plan, percoding-standards.md's "Generated code" section) whoseComposemethod builds the collection with ordinary, strongly typed C# — aforloop callingcontext.Resolve<TElement>(descriptor)per element/key/value — and registers itself via a module initializer intoCollectionPlanCache<TCollection>.Instance, mirroringPlanCache<T>'s own registration shape exactly. No reflection, noActivator, noExpression.Compileappears anywhere in this path — the generator, not the runtime, is what turns a runtime-only-knownTypeinto compile-time-knownTElement/TKey/TValuetype arguments, which is the same trickPlanCache<T>already relies on for ordinary composable types. - Dispatch stays at "stage 7."
ICompositionProvideris deliberately non-generic (ADR-0010's original Decision Outcome), so it cannot itself construct aList<Address>without either boxing/erasure or the reflection this ADR rejects — the same reason stage 8'sPlanCache<T>dispatch was never modeled as a provider in the first place. Collection dispatch has the identical shape and gets the identical treatment: insideCompositionContext.ResolveCore<TValue>, immediately after the ordinary stage-7ICompositionProvidercollection (which still exists and still holds the primitive/enum/nullable-value providers) has declined, a directCollectionPlanCache<TValue>.Instancefield read is tried — an ordinary closed-generic field read, exactly likePlanCache<TValue>.Instance's stage-8 read two lines below it — before falling through to stage 8. Stage 7 in the product-facing sense (docs/architecture.md's 9-stage list) is unchanged: one stage, tried in the same position, still "built-in value providers, including collections." Only its internal implementation is now a provider collection plus one context-owned generic-dispatch check, the same hybrid shape stage ⅔-vs-⅘/6/7 already has at the model level. Because this check runs after stages 1–6, a registration, shared value, profile rule, semantic provider, or test-double provider can still claim a specific collection type ahead of the built-in collection plan, unchanged from ADR-0013's original "collections stay ordinary pipeline requests" decision — generated containing-type plans still callcontext.Resolve<List<Address>>(descriptor)exactly as they callResolve<TParam>()for anything else; they never inline collection construction or know that aCollectionPlanCache<T>exists. CollectionElement/DictionaryKey/DictionaryValuenow flow throughCompositionRequestDescriptor, reversing ADR-0012/ADR-0013's original "provider constructs the segment directly" position. Because the collection is now built by generated code living in the consumer assembly (not by an internal runtime provider), it only has the same public surface any other generated plan has —ICompositionContext.Resolve<TValue>(in CompositionRequestDescriptor).CompositionRequestKindgains three cases —CollectionElement,DictionaryKey,DictionaryValue— andCompositionContext.Resolve<TValue>extends its descriptor-to-segment switch to cover them, usingOrdinalas the segment'sIndexexactly asConstructorParameter/RequiredMemberalready use it as their ordinal;Nameis unused for these three kinds (CompositionPath's existing display-string derivation never readsNamefor them either). This is a direct correction to ADR-0012's Decision Outcome ("CollectionElement/DictionaryKey/DictionaryValuesegments are never generator-supplied") and ADR-0013's Decision Outcome ("constructed directly by the collection provider itself... never viaCompositionRequestDescriptor") — both written when a runtime provider, not generated code, was expected to build collections.PathSegmentitself, its fork-key hashing, and the tag-collision test (ADR-0012 Amendment 2) are unaffected: only who constructs the segment changed, not its shape or its role in hashing.- Bounded duplicate-value retry (
HashSet<T>elements,Dictionary<TKey, ...>keys) lives in a small public runtime helper,UniqueValueResolver, callable from generated code. The retry loop itself (ADR-0013's bounded-retry, fork-derived-per-attempt rule) can't live in the generator's emitted code directly without duplicating non-trivial logic into every generated collection plan — instead,Componoexposes one generic, reflection-free static method generated code calls once per element/key position:public static class UniqueValueResolver { public const int MaxAttempts = 10; public static bool TryResolve<TValue>( ICompositionContext context, CompositionRequestKind kind, int position, Nullability nullability, HashSet<TValue> alreadyResolved, out TValue value); }alreadyResolvedis theHashSet<T>/dictionary-key-tracking set being built — a successful call both returns the unique value and leaves it already added, so the generated plan doesn't separately re-add it. This ispublicfor the same reasonCompositionRequestDescriptor/CompositionRequestKind/ICompositionContextalready are (ADR-0010's Visibility decision): it's a piece of the generated-code call surface, not part of the internal engine. Retry attempts stay deterministic: each attempt forks from a distinct, deterministic index derived from(position, attempt)— attempt0usespositionunchanged (so a position that never collides forks identically to how it would with no retry mechanism at all, meaning tuningMaxAttemptsnever perturbs a non-colliding result), and every retry attempt (attempt >= 1) uses a negative, disjoint index — deterministic, and never coincides with any position's own base index — rather than a non-deterministic re-roll. ExhaustingMaxAttemptsis reported by the generated plan throwingCompositionExceptionnaming the element/key type and requested count, matching ADR-0013's original diagnostic wording exactly — this stays a thrown exception at the generated-plan/outward boundary (same shape as any other terminal pipeline failure) rather than a newCompositionResultcase, sinceICompositionPlan<T>.Composereturns a plainT, not a result type, just like any other generatedComposemethod. - Native AOT/trimming position. No exception is needed: every collection plan is ordinary, fully AOT/trim-safe generated C#, same as any other composition plan.
Positive Consequences¶
- Removes the one place Milestone 2's design would have introduced reflection into the runtime hot path — the whole engine stays consistent with ADR-0001's "no reflection by default" rule, not "no reflection by default, except this one bounded case."
CollectionPlanCache<T>'s dispatch shape is identical toPlanCache<T>'s — one mental model for "how does Compono turn a runtime-only-known closed type into compile-time-typed construction," not two.
Negative Consequences¶
TransitiveClosureWalkerpicks up real new complexity (recursive collection-shape recognition, alongside its existing composable-type walk, and root-type collection classification) — accepted as the direct cost of moving this logic from a runtime bridge into the generator, where ADR-0001 says it belongs.CollectionPlanCache<T>,UniqueValueResolver, and three newCompositionRequestKindcases are newpublicsurface — larger than the retracted bridge's shape, which kept everythinginternal. This is accepted the same wayCompositionRequestDescriptor/ICompositionContextalready are: generated code lives outsideCompono's assembly, so anything it must call is necessarily part of the public generated-code contract, not a discretionary API design choice.- ADR-0012 and ADR-0013 are not re-opened or superseded by this ADR — both gain only a
Links-section pointer to this ADR, with no Decision Outcome or Amendment content rewritten, per the "an accepted ADR is a historical record" rule; this ADR is the authoritative current shape for collection-segment construction and dispatch. CollectionPlanCache<T>inherits the same cross-assembly module- initializer collision propertyPlanCache<T>already has (last assembly's module initializer silently wins if two consuming assemblies in one process both discover a plan for the exact same closed type) — a pre-existing, unresolved property of the dispatch mechanism this ADR deliberately mirrors, not a new gap; seedocs/architecture.md's Open Architectural Decisions, "Cross-assembly plan-cache collision" entry.
Pros and Cons of the Options¶
Reflection-based dispatch bridge (rejected)¶
- Good, because it needed no generator changes — collection construction logic lives entirely in the runtime.
- Good, because it's the least new
publicsurface (everything staysinternal). - Bad, because it introduces reflection (
MakeGenericMethod+CreateDelegate) into the default runtime architecture, which ADR-0001 doesn't carve an exception for regardless of how narrow or cached the reflected surface is. - Bad, because
MakeGenericMethodover a value-type argument is not Native-AOT-safe in general, introducing an AOT gap this ADR's approach avoids entirely.
Generator-emitted collection plans (chosen)¶
- Good, because it introduces zero reflection anywhere in the collection path — fully consistent with ADR-0001 and with
PlanCache<T>'s existing dispatch shape. - Good, because it's fully Native-AOT/trim-safe by construction, with no exception to document or later revisit.
- Bad, because it requires real new generator complexity (
TransitiveClosureWalkercollection-shape recognition, a new emitter and template) instead of confining the change to the runtime. - Bad, because it needs new
publicruntime surface (CollectionPlanCache<T>,UniqueValueResolver, threeCompositionRequestKindcases) that generated code depends on.
Links¶
- ADR-0001 — the no-reflection-by-default rule this ADR brings collection dispatch into compliance with.
- ADR-0004 —
PlanCache<T>'s zero-reflection closed-generic-field dispatch, the patternCollectionPlanCache<T>mirrors exactly. - ADR-0010 — the pipeline stage model (stage 7) this ADR's dispatch check fits into; its own Amendment 2 originally specified the reflection-based bridge this ADR retracts and replaces. ADR-0010's other decisions are unaffected by this ADR.
- ADR-0011 — the active-construction-frame mechanism collection elements participate in without any collection-specific recursion logic (unchanged by this ADR).
- ADR-0012 —
PathSegment/CompositionPath, whoseCollectionElement/DictionaryKey/DictionaryValueconstruction this ADR moves from a runtime provider to generated code. - ADR-0013 — the collection generation semantics (default size, key uniqueness, ordering) this ADR implements the dispatch mechanism for, without changing any of them.
docs/plans/0002-milestone-2-core-composition-engine.md— Phase 2, where this ADR's design was implemented.