[ADR-0019] Registrations and Service Provider Injection¶
Status: Accepted
Date: 2026-07-29
Decision Makers: Nick Cipollina, Claude (design review)
Context¶
Pipeline stage 3 ("exact registrations") has existed since Milestone 2 as a context-owned deterministic lookup, but Milestone 2 only exercised it through an internal test seam — no public registration API exists yet (PLAN-0002's explicitly deferred scope). docs/mvp.md's Milestone 3 lists "exact type registrations" as in-scope, and the milestone's headline new capability is service injection — letting a composed graph pull a value from an externally-configured dependency container rather than only from Compono's own registrations/providers.
Two questions this ADR settles together, because the second is a specific case of the first: (1) what does an exact registration actually look like — factory shape, duplicate handling — and (2) how does an external container (starting with the BCL's own System.IServiceProvider, not necessarily Microsoft.Extensions. DependencyInjection) participate in resolution without the core Compono package taking on a dependency it shouldn't.
Decision Drivers¶
design-decisions.mdrule 3: the coreComponopackage must never reference or know about an integration package.Microsoft.Extensions.DependencyInjectionis a NuGet package likeNSubstitute/Bogus/xunit.v3— core can't depend on it.System.IServiceProvider, by contrast, ships in the BCL itself (Systemnamespace, no package reference at all) — referencing it costs core nothing and violates nothing.docs/architecture.md's fixed 9-stage pipeline order — service injection has to slot into the existing stages, not add a tenth.docs/mvp.md's explicit "configuration conflict diagnostics" scope line.references/coding-standards.md's DI/composition rules: no service-locator pattern, all dependencies constructor-injected — a registration factory needs a narrow, deliberate resolve surface, not free rein to reach into arbitrary context internals.ADR-0012's reproducibility contract: every resolved value's random identity comes from its structural path position alone. Any new way to resolve a value (a registration factory calling back into the context) has to fit this contract, not bypass it.
Considered Options¶
Registration API shape¶
Register<T>(Func<ICompositionContext, T> factory), reusing the existing publicICompositionContext(unchanged surface from the consumer's point of view — it's the same interface generated plan code already calls) as the factory's resolve capability, plus a convenienceRegister<T>(Func<T> factory)overload for the common no-dependency case (docs/public-api.md's_ => new FakeClock()example).Register<T>(T instance)only — exact-instance registration, no factory indirection at all; anything needing construction-time logic is out of scope for M3.- A dedicated, narrower factory-context type distinct from
ICompositionContext(e.g.ICompositionRegistrationContext), exposing only what a registration factory should be able to do.
Duplicate exact registration for the same type¶
- Throw at build time.
Composer.Create(...)(viaCompositionConfiguration'sBuild()step, ADR-0017) collects every duplicate-type conflict across the whole builder chain (direct registrations and every applied profile) and throws oneCompositionConfigurationExceptionnaming every conflicting type and the source (direct call, or which profile) of each conflicting registration. - Last registration wins, silently.
- Last registration wins, with a non-throwing recorded diagnostic.
Service injection shape¶
- Native
IServiceProviderfallback inside stage 3.CompositionBuildergainsUseServiceProvider(IServiceProvider provider). Stage 3's context-owned check becomes two ordered sub-steps: (a) the exact-registration table, (b) if unregistered and a service provider was configured,provider.GetService(typeof(T))—nullmeans "not found," anything else (including a boxed default value type) is a genuine result. Only if both decline does resolution fall through to stage 4. No new pipeline stage, no public provider interface — this lives entirely inside the same context-owned stage 3 already responsible for exact-registration lookup. - A provider-shaped bridge, implementing whatever public provider-authoring interface a future ADR defines, registered into stage ⅘ like any other extensible-stage contributor.
- Defer service injection entirely to whenever the public provider extensibility question (deferred to Milestone 5 by this design review) is resolved, since a "real" DI bridge arguably wants provider semantics.
Decision Outcome¶
Registration API — Option 1, confirmed with the user during design review: Register<T>(Func<ICompositionContext, T> factory) plus a Register<T>(Func<T> factory) convenience overload. Reusing the existing public ICompositionContext avoids introducing a second, parallel "resolve" surface for no real benefit — but it requires one small, additive extension to that interface (see Amendment below), not a new type.
Duplicate registrations — Option 1, throw at build time, confirmed directly with the user: "Exact registrations should be unambiguous. Silent or diagnostic-only last-wins behavior would make profile composition order-dependent and could hide configuration mistakes." This is deliberately stricter than a typical DI container's last-registration-wins convention — Compono's registrations are meant to be a small, curated, exact-match set (docs/architecture.md's stage 3, distinct from stage 4's ordered/overridable provider rules), and an unintentional collision between two profiles (or a profile and a direct call) is exactly the kind of mistake docs/mvp.md calls out "configuration conflict diagnostics" to catch. An intentional override is a distinct, explicit future API (e.g. a TryRegister/Replace verb) if a real need for one appears — not the default behavior of Register.
Service injection — Option 1, native IServiceProvider fallback inside stage 3, confirmed directly with the user, with the exact ordering they specified: "1. Exact Compono registrations, 2. Configured IServiceProvider, 3. continue to configuration rules (stage 4) if unresolved." This is the same kind of move ADR-0014 already made for stage 7 (a context-owned deterministic stage internally trying more than one thing in order, before falling through to the next pipeline stage) — extending stage 3 the same way keeps the top-level 9-stage contract completely unchanged, adds no new public extensibility surface, and costs core Compono nothing beyond a BCL interface reference:
A richer Microsoft.Extensions.DependencyInjection-specific integration (auto- registering every service in an IServiceCollection, scoping a request-scoped IServiceProvider per composition, keyed-service support) is explicitly out of scope for Compono core and this ADR — that belongs in a future, separate optional package (a plausible Compono.Extensions.DependencyInjection, not designed here) that itself calls UseServiceProvider(IServiceProvider) as its underlying mechanism, the same way Compono.NSubstitute/Compono.Bogus build on core extension points without core knowing they exist.
Exact IServiceProvider fallback semantics, stated explicitly per design review (deliberately over-specified rather than left implicit, since a DI-bridge's edge-case behavior is exactly the kind of thing that's expensive to change once consumers depend on it):
- Exact Compono registrations always win. Stage 3's two sub-steps run in the fixed order shown above — the configured
IServiceProvideris consulted only when noRegister<T>(...)entry exists for the exact requested type. A registration never gets silently shadowed by a container entry, or vice versa. nullfromGetServicemeans "unresolved," and falls through to stage 4 — exactly like an ordinary registration miss. It is not treated as "the container affirmatively has no opinion and composition should fail here"; the pipeline simply continues.- An exception thrown by the configured
IServiceProvideris authoritative, not a decline. Stage 3 is a context-owned stage, which per ADR-0010 is allowed to reportFailure(unlike an ordinaryICompositionProvider, which can only reportNotHandled/Success). A container-thrown exception is never caught and silently downgraded to "not handled, keep trying stage 4" — it terminates resolution for that request, surfaced asCompositionExceptionwith the original exception preserved asInnerException(neverthrow ex;, percoding-standards.md's exception rules — the original stack trace is never discarded). A misconfigured container should fail loudly, the same way a throwing exact-registration factory already does. - A non-null, wrongly-typed result is a configuration-shaped failure, not an
InvalidCastException.IServiceProvider.GetService(Type)returnsobject?with no compile-time guarantee the runtime value is actually assignable toT(a misbehaving or misconfigured container implementation could return anything). Stage 3 checksresult is Texplicitly before use; a non-null, non-assignable result throws a structuredCompositionExceptionnaming the requested type and the actual runtime type returned, rather than letting an unchecked cast throw an unrelated-lookingInvalidCastExceptionseveral frames away from the real cause. - Compono never creates, resolves, or disposes a scope.
UseServiceProviderstores exactly theIServiceProviderinstance it's given and callsGetService(Type)on it directly — neverIServiceScopeFactory.CreateScope(), never anything disposal-related. The caller owns the provider and its entire lifetime (including any scoping);Componois a pure consumer of whateverIServiceProviderit's handed, for exactly as long as the enclosingComposeris used. A consumer wanting per-test-scoped container resolution is responsible for passing a differently-scopedIServiceProvidertoUseServiceProviderthemselves (e.g. per-test-class or per-test-case, at their own discretion) —Componohas no opinion on this and creates no scope of its own to have an opinion about. - Configuring more than one
IServiceProvideris a build-time conflict — the specific case of ADR-0017's Amendment, which generalizes this to every scalar configuration verb (WithSeed/WithCollectionSize/UseServiceProvider) rather than deciding it ad hoc here:UseServiceProvidercalled twice (directly, or once directly and once from a profile, or from two different profiles) throwsCompositionConfigurationExceptionrather than silently using whichever call happened last. A genuine multi-container-fallback-chain need is out of scope for M3 and not designed here; if one materializes later, it gets its own ADR rather than silently falling out of "last call wins."
ManualResolve: the descriptor-less Resolve<T>() overload¶
Registration factories (and, per ADR-0020, rule factories) are hand-written by a consumer, not generated code — they can't construct a CompositionRequestDescriptor naming a constructor-parameter/required- member position, because there isn't one. ICompositionContext gains a second, descriptor-less overload for exactly this case:
public interface ICompositionContext
{
T Resolve<T>(in CompositionRequestDescriptor descriptor); // unchanged, generated-code path
T Resolve<T>(); // new: manual/factory path
}
This is purely additive to the Accepted ADR-0010 contract — generated code is unaffected, still calls the descriptor overload exclusively.
A new PathSegment kind, ordinal-based like ConstructorParameter/RequiredMember (ADR-0012's existing shape, extended additively — that ADR's own Accepted text is unedited by this ADR):
internal abstract record PathSegment
{
// ...existing cases unchanged...
internal sealed record ManualResolve(int Ordinal) : PathSegment;
}
Ordinal is not derived from the requested type or any user-supplied name — it's a call-sequence counter, scoped to what this ADR calls a manual-resolve invocation frame:
CompositionContextpushes exactly one manual-resolve invocation frame immediately before calling a registration or configuration-rule factory, and pops it in afinallyimmediately after that factory call returns or throws — the frame's lifetime is scoped to that one factory invocation, full stop, not to any broader notion of "the current node."- The frame holds a single mutable counter, starting at
0. Every descriptor-lesscontext.Resolve<T>()call made during that same factory invocation — however many, for whatever types — reads the counter for itsManualResolve.Ordinaland increments it: two sibling calls inside one factory body share and advance the one counter (first call gets0, second gets1, and so on). - If that factory's own
Resolve<T>()call resolves a type whose construction itself invokes another registration/rule factory (a nested factory invocation, not merely a nested generated-plan dispatch),CompositionContextpushes a new, independent frame with its own counter starting at0for that inner invocation — never the outer frame's counter continued. This is what keeps a nested factory'sManualResolve(0)from colliding with the outer factory's ownManualResolve(0): they're disambiguated the same way any other nestedResolve<T>()call already is, by being children of different path nodes, not by the counter itself being aware of nesting. - Because the frame is popped in
finally, a factory that throws never leaves its counter (or any other invocation-frame state) reachable by a later, unrelated request — there is nothing to "leak" across requests, structurally, since the frame object itself stops being referenced from anywhere once its owning call returns or throws, the same guarantee the active-construction-frame stack (ADR-0011) already relies on for the identical push-before/pop-in-finallyshape.
Why ordinal, not requested type, as ManualResolve's identity. This is the same reasoning ADR-0012 Amendment 1 already applied to ConstructorParameter/RequiredMember: a factory that calls context.Resolve<IClock>() then context.Resolve<IRandomService>() makes two structurally distinct draws. If ManualResolve had no identity beyond "this is a manual resolve," both calls would derive an identical fork key from the same parent state — the exact silent-collision bug class that amendment and the original Fnv1a fix (PLAN-0002 Phase 1 notes) already closed for sibling constructor parameters. Call-sequence ordinal avoids it the same way constructor-parameter ordinal does, without needing the requested type (which ADR-0012's Decision Outcome already bans from hashing) as an input.
Verified against ADR-0012's reproducibility contract, concretely:
- Sibling manual resolves receive distinct paths. Given
Register<Foo>(context => new Foo(context.Resolve<IClock>(), context.Resolve<IRandomService>())), the first call's path is...→Foo→ManualResolve(0), the second is...→Foo→ManualResolve(1)— distinct fork keys regardless ofIClock/IRandomServicenever sharing a requested type, by the same tag+ordinal mechanism every other segment kind uses. - Nested factories preserve deterministic paths. If
IClock's own resolution (whether built-in, registered, or generated-plan-backed) itself nests further requests, those requests append their own segments as children of...→ManualResolve(0), exactly as any other nestedResolve<T>()call already appends children of its caller's node —ManualResolveparticipates in the same parent-chain forking as every other kind; no special-casing exists or is needed. - Recursion detection required a genuine correction — the original claim here was wrong. An earlier draft of this ADR asserted that a factory resolving its own declared type again "is caught by the existing frame-stack check, unmodified." That's false:
Register<IClock>(context => context.Resolve<IClock>())'s nestedResolve<IClock>()call re-enters the pipeline at stage 3, which matches theIClockregistration unconditionally, before stage 8 is ever reached — it re-invokes the same factory, which callsResolve<IClock>()again, forever. ADR-0011's active-construction-frame stack is checked only immediately before generated-plan dispatch (stage 8); a cycle confined entirely to stage 3 (exact registrations) or stage 4 (configuration rules) never reaches stage 8 at all, so that check never runs and this recurses untilStackOverflowException. The identical gap applies to a configuration-rule factory (.For<Customer>().Member(x => x.Y).Use(context => context.Resolve<Customer>()...)) resolving its own declaring type.
Fix, corrected once more after a second review pass: track re-entrance into the same factory, not "is this type already under construction anywhere." A first attempt at this fix reused ADR-0011's type-keyed active-construction-frame stack directly — pushing the requested type before invoking any registration/rule factory targeting it, checked against the same stack stage 8 already pushes onto. That's wrong, and rejects a legitimate, already-documented pattern: per docs/architecture.md's Recursion Detection section, a self-referencing type terminated by a registration or shared value is explicitly supposed to never touch the recursion mechanism at all. Consider a self-referencing Node with a Node? Child property, composed via its generated plan (which pushes Node onto the stage-8 frame stack), where a member rule terminates the graph: .For<Node>().Member(x => x.Child).Use(_ => new Node(null)). Sharing one type-keyed stack between stage 8 and factory invocation means invoking this rule's factory — which doesn't recurse at all, it just returns a leaf value directly — would see Node already active (the outer generated plan's own frame) and reject it as a false cycle, before the factory ever runs. Type-keyed tracking conflates "some type is under construction somewhere in the ancestor chain" (ordinary graph shape, not a cycle — docs/architecture.md's own distinction) with "this specific factory is calling back into itself" (the actual cycle this ADR needs to catch).
The corrected guard tracks factory re-entrance by identity, in a stack separate from ADR-0011's type-keyed one, never shared with stage 8: CompositionContext pushes the specific Func<...> delegate instance about to be invoked (the exact registration's factory, or the exact compiled rule's factory — each registration/ rule captures exactly one such delegate once, at configuration time, reused for every resolution that reaches it) onto this new stack immediately before invoking it, and pops it in a finally immediately after — same push-before/pop-in-finally shape as every other frame mechanism in this codebase, just keyed by delegate identity instead of Type. If that exact delegate is already active on this stack, invoking it again is a genuine self-recursive cycle (the delegate calling back into resolving a request that routes to itself, directly or transitively) — it fails with the same kind of diagnosable CompositionException stage 8's cycle detection already produces, never a StackOverflowException. Two different registrations/rules that happen to both target the same type (or a rule and an enclosing generated plan for the same type, as in the Node.Child example above) are different delegate instances — no collision, exactly the outcome docs/architecture.md already documents as correct. Register<IClock>(context => context.Resolve<IClock>()) is still caught correctly: the nested Resolve<IClock>() call routes to stage 3, finds the same registration, and would invoke the same delegate instance again — already active on the factory-reentrance stack, correctly rejected. - Reproducible across repeated compositions with the same seed. Ordinal depends only on call sequence within one deterministic factory/rule invocation — given the same configuration (same registrations/rules, same call graph), two independent Create<T>() calls with the same seed invoke every factory the same number of times, in the same order, producing identical ManualResolve ordinal sequences and therefore identical fork keys and output. This assumes factories/ rules are side-effect-free with respect to how many times they call Resolve<T>() — the same assumption generated code's own determinism already rests on.
Positive Consequences¶
- Service injection ships with zero new public extensibility surface and zero new package dependency for core
Compono—System.IServiceProvideris already part of the BCL everyCompono-referencing project already has. - Duplicate-registration conflicts fail loudly and specifically, at the moment a consumer actually made the mistake (
Composer.Create(...)), not silently or three layers removed from the cause. - Registration factories reuse the exact same public resolve surface generated code and (per ADR-0020) rule factories use — one
ICompositionContextcontract for every "code that needs to resolve a nested value" case, not three parallel ones. - Explicitly deferring MEDI-specific integration (
IServiceCollection, scoping, keyed services) keeps this ADR's actual surface small and matches the "avoid designing for hypothetical future requirements" standard — that richer package, if it's ever built, gets its own ADR against real requirements.
Negative Consequences¶
- Strict throw-on-duplicate means an intentional override pattern (a test-specific
Composer.Createwanting to override a profile'sIClockregistration) has no first-class API in M3 — a consumer has to avoid registering the same type twice across profiles/direct calls, or compose profiles more granularly. Accepted per the user's explicit direction; a deliberateReplace/TryRegisterverb is a candidate for a future ADR if this friction turns out to be real once M3 ships. - The
ManualResolveinvocation-frame counter is a small but genuine addition toCompositionContext's internal per-request state — not free, though bounded (oneintper active manual-resolve invocation, popped when that invocation returns). - A separate, factory-identity-keyed re-entrance stack (not shared with ADR-0011's type-keyed one) is genuine new logic and new per-context state this ADR adds — corrected twice: first from an earlier draft that incorrectly assumed the existing stage-8-only check already covered this case at all, then from a second draft that shared ADR-0011's type-keyed stack directly and would have rejected legitimate cycle-terminating rules as false positives. Needs its own regression tests: a self-referencing registration/rule failing with a diagnosable exception instead of a
StackOverflowException, and a rule that legitimately terminates a self-referencing generated-plan graph (theNode.Childcase above) succeeding rather than being incorrectly rejected — both matter, not just the failure case. - The single-
IServiceProviderrestriction means a consumer wanting a fallback chain across multiple containers has no supported way to express it in M3 — accepted as an explicit non-goal rather than a gap, since no concrete use case motivated it during this design review. IServiceProvider.GetService(typeof(T))is called for every stage-3 miss onceUseServiceProvideris configured, even for ordinary domain types no container registration exists for — a real, if generally cheap, per-request cost that only applies when a consumer opts in.
Pros and Cons of the Options¶
Registration API — Option 1 (reuse ICompositionContext, plus Func<T> overload)¶
- Good, because it's zero new public types.
- Good, because a registration factory and generated code share identical resolve semantics — no special-cased "factory-only" behavior to document separately.
- Bad, because it means extending an
AcceptedADR-0010 contract (additively) rather than a clean, standalone factory type — judged acceptable since the extension is purely additive and doesn't change the existing descriptor overload at all.
Registration API — Option 2 (instance-only)¶
- Good, because it's the simplest possible shape.
- Bad, because it can't express
docs/public-api.md's own leading example (Register<IClock>(_ => new FakeClock())) — a registration whose value needs to be constructed, not just handed over as an already-built instance, is exactly the common case in practice (most fakes/doubles have some construction logic, even if trivial).
Registration API — Option 3 (dedicated factory-context type)¶
- Good, because it could expose a deliberately narrower surface than
ICompositionContext. - Bad, because
ICompositionContextis already deliberately minimal (one method,Resolve<T>, per ADR-0010) — there's nothing left to narrow. A second type with the identical shape is pure duplication.
Duplicate registrations — Option 2 (silent last-wins)¶
- Good, because it's the most permissive, matches common DI-container convention.
- Bad, because it makes
AddProfilecall order load-bearing for correctness in a way nothing in the builder's public shape signals — directly rejected by the user for hiding configuration mistakes.
Duplicate registrations — Option 3 (last-wins + diagnostic)¶
- Good, because it's deterministic and still surfaces the conflict somewhere.
- Bad, because "somewhere" (a log, an inspectable diagnostics list) is easy to miss entirely — a build-time throw is the only version of this that a consumer can't accidentally ignore.
Service injection — Option 2 (provider-shaped bridge)¶
- Good, because it would reuse whatever extensibility mechanism M5 eventually builds.
- Bad, because it requires that mechanism to exist now, directly contradicting the "defer public provider extensibility to M5" decision this same design review just made —
IServiceProvidersupport would then be blocked on a decision explicitly deferred for having no real M3 consumer yet, whenIServiceProvideritself is a real M3 consumer need.
Service injection — Option 3 (defer entirely)¶
- Good, because it avoids the ordering question above entirely.
- Bad, because "service injection" is
docs/mvp.md's (and the user's design-review prompt's) named headline capability for this milestone — deferring it wholesale would leave Milestone 3 without its own stated primary deliverable.
Links¶
- docs/mvp.md — Milestone 3 scope ("exact type registrations"), and this design review's service-injection framing
- docs/public-api.md — Registrations section (
Register<T>examples already shown there remain valid under this decision) - docs/architecture.md — Resolution Pipeline stage 3, and stage 7's existing hybrid-stage precedent (
CollectionPlanCache<T>alongside the ordered built-in provider list) - ADR-0010 — the
ICompositionContext/CompositionRequestDescriptor/CompositionRequestKindcontract this ADR additively extends - ADR-0011 — the type-keyed active-construction-frame stack (stage 8 only) this ADR's own, separate factory-identity-keyed re-entrance guard is deliberately not merged with, and the "self-referencing type terminated by a registration/rule never touches recursion detection" architectural rule this ADR's corrected design now actually preserves rather than accidentally violating
- ADR-0012 — the structural-forking reproducibility contract
ManualResolve's ordinal counter exists to preserve - ADR-0017 — the build-time validation pass duplicate-registration detection runs inside
- ADR-0020 — rule factories, the other consumer of the descriptor-less
Resolve<T>()overload