Skip to content

Resolve discovered command and query extension points from the request scope - #2529

Merged
woksin merged 3 commits into
mainfrom
fix/command-collections-scoped-resolution
Aug 12, 2026
Merged

Resolve discovered command and query extension points from the request scope#2529
woksin merged 3 commits into
mainfrom
fix/command-collections-scoped-resolution

Conversation

@woksin

@woksin woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixed

woksin and others added 3 commits August 12, 2026 10:44
…t scope

The collections that hold Arc's discovered extension points - command response
value handlers, command filters, command execution scopes and query filters -
are singletons, and IInstancesOf<T> resolves each implementation from the
IServiceProvider that constructed it. For a singleton that is the root
provider, where an implementation depending on a scoped service cannot be
created at all: the container rejects it outright once scope validation is on,
which is what the host enables in Development.

Every Chronicle command response value handler depends on the scoped IEventLog,
so a Development application throws on every command that returns an event.

Keep the collections singletons - they are held by the command and query
pipelines, which are singletons because they create the scope the work runs in
- and resolve their implementations from that scope at the point of use
instead, through the IServiceProvider already carried on CommandContext and
QueryContext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the defect from both ends: a real Development host with scope
validation on, resolving a command response value handler that needs a scoped
service, and unit specs pinning that each collection resolves from the scope
the work carries rather than the provider that constructed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It is a lifetime detail of the pipelines that use it, not public surface,
matching the sibling ParameterDependencyResolver in the same namespace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@woksin woksin added the patch label Aug 12, 2026
@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Reviewer context.

The mechanism is one step further down than #2505 describes. CommandResponseValueHandlers does not capture the handler instances at construction — InstancesOf<T>.GetEnumerator() calls _serviceProvider.GetService(type) on every enumeration. What it captures is the IServiceProvider. IInstancesOf<> is registered transient in Fundamentals (TypesServiceCollectionExtensions.cs:36), so it binds to whichever provider resolves it — and a singleton resolves it from the root. The failing stack confirms it:

System.InvalidOperationException: Cannot resolve '…HandlerNeedingAScopedCollaborator' from root provider
  because it requires scoped service '…ScopedCollaborator'.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteValidator.ValidateResolution(…)
   at Cratis.Types.InstancesOf`1.GetEnumerator()+MoveNext()
   at Cratis.Arc.Commands.CommandResponseValueHandlers.CanHandle(…)

This matters for the choice of fix: it is not about when handlers are built, it is about which provider builds them.

Why resolve-per-invocation rather than a scoped registration. Making the collections scoped only moves the failure. CommandPipeline is a singleton — necessarily, it owns the IServiceScopeFactory that creates the scope a command runs in — and it takes ICommandFilters, ICommandResponseValueHandlers and ICommandHandlerProviders as constructor dependencies. A singleton consuming a scoped service is itself a captive dependency, so the container would reject that too. (CommandResponseValueHandlers is also registered twice — the explicit AddSingleton and the [Singleton] convention binding — so a lifetime change means changing both.)

Resolving from the scope at the point of use is what Arc already does everywhere else in this pipeline: TransactionalCommandScope, CommandProvideInvoker and CommandHandlerArgumentResolver are all [Singleton] and take the scoped provider per invocation, and IdentityEndpointMapper.cs:134 already resolves IInstancesOf<T> from RequestServices. The provider is the one CommandContext/QueryContext already carry, and the HTTP path supplies context.RequestServices. It falls back to the injected collection when there is no provider, which is why existing specs are untouched.

Audit of the siblings in CommandServiceCollectionExtensions, all confirmed by running them against a real Development host rather than by reading:

Collection Verdict
ICommandResponseValueHandlersICommandResponseValueHandler reproduced, fixed
ICommandFiltersICommandFilter reproduced, fixed — public extension point
CommandPipelineICommandExecutionScope same shape, fixed — public extension point
ICommandHandlerProvidersICommandHandlerProvider reproduced, deliberately not fixed — see below
ICommandProvideInvoker, ICommandHandlerArgumentResolver already correct; take the scoped provider per call

ICommandHandlerProvider fails the same way, but it is not the same defect. It builds the Type → ICommandHandler map once at startup, before any scope exists, and that map outlives every scope. A provider requiring a scoped service is a lifetime error in the implementation, not in Arc's registration — there is no correct scope to resolve it from. Left as is, deliberately.

Scope note — this PR also fixes QueryFilters, which is outside the issue. It is the identical shape (singleton holding IInstancesOf<IQueryFilter>, QueryContext already carrying the provider), and shipping the command-side fix alone would leave a Development app still throwing for a query filter with a scoped dependency. Say the word and I will split it out.

Verification. The four unit specs were run against the unfixed source: 9 of the 10 facts fail, and the host spec fails with the exception above. With the fix, all pass. Debug and Release builds clean with zero warnings on Arc.Core, Arc, Chronicle and the spec projects. Specs: Arc.Core.Specs 1925, Chronicle.Specs 632, Arc.Specs 274, MongoDB.Specs 192, Swagger.Specs 47, Testing.Specs 19, OpenApi.Specs 8 — 3,097 passing, 0 failing. The full-solution build could not run in this worktree because ProxyGenerator.Specs shells out to yarn build and the worktree has no node_modules; that is a worktree artifact and unrelated to the change — CI covers it.

Not verified: no Chronicle-backed application was started end to end against these bits. The host spec reproduces the exact container failure with a stand-in scoped service rather than the real IEventLog.

@woksin
woksin merged commit 18565d4 into main Aug 12, 2026
54 of 55 checks passed
@woksin
woksin deleted the fix/command-collections-scoped-resolution branch August 12, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant