Skip to content

Bring DI lifetime handling into conformance with Brighter (#377) - #383

Merged
iancooper merged 188 commits into
masterfrom
feature/377-di-lifetime-conformance
Oct 19, 2026
Merged

iancooper merged 188 commits into
masterfrom
feature/377-di-lifetime-conformance

Conversation

@iancooper

Copy link
Copy Markdown
Member

Closes #377. Also closes #378, #379 and #380.

What this does

A Darker handler can now share the Scoped services of an ASP.NET Core request, such as an EF Core DbContext, with the controller and Brighter handlers. Register Scoped handlers and call AddDarkerRequestScope() from the new Paramore.Darker.Extensions.AspNetCore package. Making the QueryProcessor Scoped never achieved this, and the guidance in CLAUDE.md and the README is corrected.

Spec: specs/015-di_lifetime_conformance/. Design: ADRs 0022, 0023 and 0024. ADR 0014 stays Accepted and is noted as partially superseded.

Lifetime validation stays in #305.

Breaking changes

Custom factory implementations must change. The full list, with migration notes, is in the spec 015 section of release_notes.md:

  • async factory Release becomes ValueTask ReleaseAsync, and IAmALifetime extends IAsyncDisposable;
  • handler factories gain CreatePipelineScope();
  • IAmALifetime.Add is removed;
  • decorators Darker registers are Transient unless HandlerLifetime is Singleton.

Under JoinAmbient, queries in one scope share its Scoped services, including a Scoped handler instance. Concurrent queries in one request need AlwaysNew.

Samples

SampleMinimalApi gains a /request-scope endpoint that shows the endpoint and a Scoped handler seeing the same Scoped service.

Verification

  • dotnet build Darker.Filter.slnf -c Release succeeds, and the full suite passes on net8.0 and net9.0.
  • Two adversarial code review passes were run. Findings were fixed, apart from the ones below.

Not in this PR

  • Removing the two existing InternalsVisibleTo entries, and a test that finds an internal type by name: Remove InternalsVisibleTo from the test setup #382.
  • xUnit1051 warnings in test files that predate this branch.
  • The optional Singleton-path benchmark (T5.9) was not run.
  • Sample files carry no licence header, matching the existing samples.

Review notes

This is a large diff (about 190 commits). The Phase 3 and Phase 4 tests were reviewed in batches, and the Phase 4 ralph run (T4.6 to T4.9) was reviewed afterwards. T4.8's second mutation is covered by the disposed-scope probe, which was accepted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj

iancooper and others added 30 commits October 8, 2026 20:14
Spec 015 (#377): requirements for DI lifetime conformance with Brighter,
and ADR 0022 deciding that teardown releases every component in its own
fault boundary, disposes the per-query lifetime last, never throws, and
is awaited on the async and stream paths (#378).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0023 (#379): the handler factory creates each query's pipeline scope
up front via CreatePipelineScope(), the lifetime carries it as an
IAmAScope handle, and factories resolve only from that handle or fail
fast. Removes the DI factory's ConditionalWeakTable and
IAmALifetime.Add; amends ADR 0022's IAmALifetime contract to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0024 (#380): with ScopeAffinity.JoinAmbient and a Scoped handler
lifetime, the DI factory adopts the ambient scope offered by an
IAmAScopeProvider, after a probe, wrapped in a non-owning handle so
Darker never disposes it; otherwise it falls back to an owned scope
with a once-per-container Warning. Adds the
Paramore.Darker.Extensions.AspNetCore package with
AddDarkerRequestScope().

ADR 0014 stays Accepted with a partial-supersession note: its
scope-ownership account and Scoped-QueryProcessor paragraph are
superseded by 0024. ADR 0023's factory table row 4 now reads
'not a recognised scope' to match 0024's widened recognition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Records the DI lifetime conformance change (#377) under ## Master:
request-scope sharing via AddDarkerRequestScope(), robust teardown,
and the seven breaking changes from ADRs 0022 and 0023.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
…riteria

/spec:write_release_notes held a marked section only up to the next
heading of any depth, which stops at "#### Breaking changes"; a literal
replace duplicated the breaking-changes list. The extent now runs to the
next ### or ## heading, and hand-written #### subsections are carried
over verbatim on replace so a re-run never deletes them.

/spec:review design did not grade Implementation Approach test plans
against .agent_instructions/testing.md, so a plan to add
InternalsVisibleTo for testing was scored as acceptable. Design reviews
now read testing.md and score a plan that breaks one of its explicit
rules as High.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Issues worked across requirements, ADRs 0022-0024, the 0014 status note
and the release notes, so each concept is stated one way everywhere:

- Teardown guarantee on the DI path: Darker isolates its own steps and
  always disposes the scope; faults inside one DI scope's disposal are
  the container's. AC1, NFR Reliability and Constraints reworded.
- Instance sharing under JoinAmbient (redesign): decorators are
  registered Transient unless HandlerLifetime is Singleton; a shared
  Scoped handler instance is accepted, documented and pinned. FR11 and
  NFR Concurrency reworded; Singleton decorator sharing out of scope.
- Disposed borrowed scopes: no ConfigurationException wrap; a scope
  disposed before the build fails the probe; later disposal surfaces
  unwrapped. FR17 reworded, constructor and step removed.
- Warning conditions: only misconfiguration fallbacks warn (FR15).
- Testing plan (redesign): no InternalsVisibleTo; FR10's guard has no
  direct test and its AC is replaced by a public-surface criterion.
- New ACs for partial-build release and release through the creating
  factory; release notes gain the missing items and an Added list.
- Structure: 0024 invariants moved under the mechanism, unrequested API
  given a Scope bullet, 0014 note lists every amended decision.

Includes the round 1 findings in review-design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Issues worked, each swept across ADRs 0022-0024, requirements and the
release notes:

- 0024 records its two decisions: the decorator clause is in the
  Decision sentence and the shared "Where this ADR sits" row, and
  "unchanged without the opt-in" is narrowed to scope behaviour (FR14).
- Decorator isolation is stated for decorators Darker registers; a
  user's own TryAdd-preserved registration is a recorded Negative.
- Step 7 tests use a recording decorator test double keyed on a shared
  result type, so they can be written through the public surface.
- The lifetime logs through the pipeline builder's per-TResult logger,
  so teardown log assertions do not depend on test order.
- The AdoptionDiagnostics latch is atomic, tested under concurrency.
- Teardown tolerates a builder that never built (row 1).
- Release notes: Simple factories' async disposal, decorator-factory
  migration for IAmALifetime.Add; 0022 lists its test doubles.

Includes the round 2 findings in review-design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Approves the design for #377: robust pipeline teardown, the pipeline
scope handle, and ambient scope adoption. ADR 0014 stays Accepted,
partially superseded by a prose note in its Status. Regenerates the ADR
index and adds the design approval marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Adds tasks.md (88 tasks across five phases) and the tasks review.
Issues worked, each swept across the task list and its coverage
appendix:

- RED mutations that could not fail: T1.3 now removes Clear() as well
  as the guard; T4.8 breaks the lazy stream build, and runs a query
  first so the provider mutation has something to remember.
- ADR 0022 row 1 gets its own task (T1.17a): no release of a null
  handler, and teardown tolerates a builder that never built.
- Async teardown isolation: T1.12 is trimmed, T1.13 adds the async
  per-item boundary, T1.14/T1.15 keep each awaited release inside its
  boundary, T1.16 becomes CHARACTERISE, and T1.16a covers the async
  guarded log. T1.13a covers the DI teardown fault on async paths.
- Test migration (T2.10) is CHARACTERISE with a mutation per file, and
  deletes the multi-item tests that would duplicate T2.6.
- Padding removed: T2.14 is a VERIFY with named mutations, T3.13 drops
  its owned fact, and T3.17 now pins the duplicate-decorator change.
- Untested public behaviour: null guards (T3.8a, T4.2) and provider
  replacement (T3.8b); T3.3 is plumbing only.
- T3.24 gates with a TaskCompletionSource and checks per-reason
  latching; T2.4, T3.5 and T2.12 wording fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
…te methods

Structural only (ADR 0022 step 1); no behaviour change, suite green before and after.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_a_tracked_lifetime_item_throws_on_dispose_should_dispose_the_others_and_log_error
- Implementation: QueryLifetimeScope takes an ILogger and wraps each item's Dispose in try/catch, logging at Error with the item type

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_factory_disposes_the_lifetime_and_the_pipeline_disposes_it_again_should_dispose_each_item_once
- Characterisation — RED observed via: deleting the _disposed guard and _trackedDisposables.Clear() in QueryLifetimeScope.Dispose (DisposeCount 2, expected 1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
…ped service

- Test: When_execute_tears_down_a_scope_holding_an_IAsyncDisposable_only_service_should_log_error_and_return_result
- Characterisation — RED observed via: removing the per-item try/catch in QueryLifetimeScope (InvalidOperationException escapes Execute)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_the_handler_dispose_throws_on_execute_should_still_release_decorators_dispose_lifetime_and_log_at_error
- Implementation: PipelineBuilder.Dispose wraps each release in its own try/catch, logging at Error with the component type; the lifetime is still disposed last

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_the_handler_throws_and_its_release_also_throws_on_execute_should_surface_the_handlers_exception
- Characterisation — RED observed via: rethrowing from the handler-release catch in PipelineBuilder.Dispose (caller saw the release fault, not HandlerFaultException)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
… disposed

- Test: When_the_handler_and_both_decorators_throw_on_release_should_log_one_error_per_fault_dispose_lifetime_and_return_result
- Characterisation — RED observed via: replacing the per-decorator try/catch in PipelineBuilder.Dispose with one around the whole decorator loop (Error-count assertion fails)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_a_scoped_service_throws_on_dispose_inside_the_per_query_scope_should_return_the_result_and_log_at_error
- Characterisation — RED observed via: removing the per-item try/catch in QueryLifetimeScope.Dispose (Should.NotThrow fails, the dependency's exception escapes)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_the_logging_provider_throws_while_teardown_logs_a_lifetime_fault_should_still_return_the_result
- Test: When_the_logging_provider_throws_while_teardown_logs_a_release_fault_should_still_return_the_result
- Implementation: PipelineBuilder and QueryLifetimeScope log teardown faults through a private TryLogTeardownFault helper with its own try/catch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
- Test: When_a_decorator_throws_during_initialisation_on_execute{,_async,_stream}_should_release_the_handler_and_every_created_decorator
- Test: When_a_decorator_cannot_be_created_on_execute_should_release_the_handler_and_the_decorator_created_before_it
- Implementation: GetDecorators, GetDecoratorsAsync and GetStreamDecorators assign the decorator list to its field before the loop and add each decorator straight after Create, so Dispose sees it even when Create or InitializeFromAttributeParams throws

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XnmfNMt1xWhtLYLknDZx6Z
iancooper and others added 13 commits October 17, 2026 07:38
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
- Scoped RequestCorrelation shared by the endpoint and a Scoped handler via /request-scope
- Verified by running the sample: endpoint and handler ids match

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
…R index (T5.7)

Status stays Accepted per adr_frontmatter.md; the body Status prose already records
the partial supersession by ADRs 0022, 0023 and 0024.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
…ld, no drift)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
…g message

- Test: When_the_logging_provider_throws_on_the_debug_message_that_starts_teardown_should_still_return_the_result (sync) and the async twin
- Implementation: PipelineBuilder logs the teardown start through a guarded helper on Dispose and DisposeAsync
- Code review finding 3 (specs/015-di_lifetime_conformance/review-code.md)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
…structors in the AspNetCore types

Code review findings 6 and 7 (specs/015-di_lifetime_conformance/review-code.md). No behaviour change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
…ts classes, xUnit1051)

- TestDoubles: split multi-type files so each type has its own file
- Test classes are named [Behavior]Tests; files are named after their happy-path method
- Pass TestContext.Current.CancellationToken where xUnit1051 asked for it
- No behaviour change; test counts unchanged on net8.0 and net9.0

Code review findings 1, 2 and 8 (specs/015-di_lifetime_conformance/review-code.md).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
… letting it escape

- Test: When_the_offered_scope_throws_when_asked_for_its_service_provider_should_use_owned_scopes_and_log_one_warning
- Implementation: the probe reads Services once inside its fault boundary
- Code review finding 5 (specs/015-di_lifetime_conformance/review-code.md)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
Code review finding 9.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
The earlier tidy (9fbf92e) renamed the Extensions.Tests files but left 33 classes named When_*.
Test method names and counts are unchanged (Extensions 107 on net8.0 and net9.0).

Code review pass 2, finding 1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiLKqEzdGHZdixwJUcenrj
@iancooper iancooper self-assigned this Oct 19, 2026
@iancooper
iancooper marked this pull request as ready for review October 19, 2026 09:51
@BrighterCommand BrighterCommand deleted a comment from claude Bot Oct 19, 2026
@BrighterCommand BrighterCommand deleted a comment from claude Bot Oct 19, 2026
@iancooper

Copy link
Copy Markdown
Member Author

@claude Let's review this, as it is a significant change. We have two goals here: improve our handling of the lifetime of components that Brighter creates through a factory, to ensure that we don't leak, and we don't carry state between operations if the user did not ask for that. Second, to allow us to share a ServiceCollection and resolution, with an HTTPContext (and Brighter) such that we would resolve a dependency with state, such as an EF DbContext, from the same ServiceCollection scope

@claude

claude Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Claude encountered an error after 3m 17s —— View job


Review: DI lifetime conformance with Brighter (#377)

Todo list

  • Gather context (PR description, ADRs 0022/0023/0024, changed files)
  • Read core teardown changes (PipelineBuilder.cs, QueryLifetimeScope.cs, IAmALifetime.cs)
  • Read scope-handle changes (IAmAScope.cs, IQueryHandlerFactory(Async).cs, SimpleHandlerFactory.cs)
  • Read ambient scope adoption changes (ScopeAffinity.cs, IAmAScopeProvider.cs, ServiceProviderComponentFactory.cs, BorrowedServiceProviderScope.cs)
  • Read the AspNetCore extension package (HttpContextScopeProvider.cs, HttpRequestScope.cs, DI registration)
  • Cross-check against ADRs 0022/0023/0024 and the doc/README/CLAUDE.md corrections
  • Post review findings

Goal 1 — lifetime/leak handling (ADR 0022 + 0023)

This is a solid, well-contained design:

  • PipelineBuilder<TResult> (src/Paramore.Darker/PipelineBuilder.cs) now creates the per-query QueryLifetimeScope before resolving the handler (PipelineBuilder.cs:69, :131, :291), so a scope created during resolution is tracked even if a later step in Build/BuildAsync throws. Decorators are added to their tracking list the moment they're created (:272, :433), with a comment explaining this is so Dispose can release them even if InitializeFromAttributeParams throws — good defensive sequencing.
  • Teardown never throws: every release (TryRelease/TryReleaseAsync) is wrapped and logged at Error (PipelineBuilder.cs:534-556), and even the logging calls themselves are wrapped (TryLogTeardownFault, :572-582) so a faulty logging provider can't break teardown — matches ADR 0022's guarantee.
  • The scope is always disposed last (Dispose()/DisposeAsync() at :447-468), and release goes through the factory that actually created the handler (_handlerCreatedByAsyncFactory, :487-500) rather than assuming sync vs async — this correctly avoids releasing through the wrong factory.
  • Checked for a sync/async mismatch leak: QueryProcessor.Execute (sync) only ever calls Build(), which populates only _decorators, so sync Dispose() not touching _asyncDecorators/_streamDecorators is safe — those lists stay null and are only populated (and only disposed) on the async/stream paths, which always go through DisposeAsync() (QueryProcessor.cs:107, :169). No leak here.
  • CreatePipelineScope() on ServiceProviderComponentFactory (src/Paramore.Darker.Extensions.DependencyInjection/ServiceProviderComponentFactory.cs:62-75) returns null for Singleton (nothing to dispose) and otherwise either adopts an ambient scope (never disposed, per BorrowedServiceProviderScope) or creates a ServiceProviderLifetimeScope that Darker owns and disposes exactly once. If CreatePipelineScope() itself throws (e.g. AmbientScopeSourceException from a faulty provider), nothing has been allocated yet, so there's nothing to leak, and _lifetime stays null-safe through PipelineBuilder.Dispose/DisposeAsync.

I didn't find a leak or double-dispose path in this part of the diff.

Goal 2 — sharing scope with HttpContext/EF DbContext (ADR 0024)

  • ScopeAffinity.JoinAmbient + HandlerLifetime = Scoped is the only combination that adopts; everything else (wrong lifetime, no provider, non-IAmAServiceProviderScope offering, root container, offering from another container, a throwing provider/Services getter) is declined with a single Warning per reason via AdoptionDiagnostics (ServiceProviderComponentFactory.cs:79-159), never an exception that would break the query. This matches ADR 0024's decision ladder.
  • The "is this the root container" check (IsRoot, ServiceProviderComponentFactory.cs:144-145) relies on the Microsoft.Extensions.DependencyInjection implementation detail that only the root provider answers IServiceProvider and IServiceScopeFactory with the same object — subtle, but it's exercised by When_the_offered_scope_is_not_an_IAmAServiceProviderScope_should_use_owned_scopes_and_log_one_warning.cs and the AspNetCore test suite (test/Paramore.Darker.Extensions.AspNetCore.Tests/), which covers no-HttpContext, throwing providers, call-order independence, and successive requests reusing separate scopes. Good edge-case coverage for something this implementation-detail-dependent.
  • HttpContextScopeProvider/HttpRequestScope (src/Paramore.Darker.Extensions.AspNetCore/) are a clean, minimal non-owning handle: Dispose()/DisposeAsync() are no-ops because ASP.NET Core owns the request scope, consistent with "Darker never disposes an adopted scope."
  • Docs are corrected consistently: CLAUDE.md, README.md, and the new SampleMinimalApi /request-scope endpoint all now say the same thing — a Scoped QueryProcessor does not share the request's DbContext; what's needed is HandlerLifetime = Scoped + AddDarkerRequestScope(). The old (incorrect) "QueryProcessor must be Scoped" guidance is gone everywhere I checked (git diff origin/master...HEAD -- CLAUDE.md README.md).
  • The documented hazard — concurrent queries in one request sharing a Scoped handler instance and tripping EF Core's "no concurrent operations" guard under Task.WhenAll — is called out in the XML docs on ScopeAffinity.JoinAmbient, AddDarkerScopeProvider, and AddDarkerRequestScope, and in CLAUDE.md/README. That's the right way to handle an inherent sharp edge of the feature rather than trying to paper over it.

Minor observations (not blocking)

  • samples/SampleMinimalApi/Program.cs still has no trailing newline at EOF (pre-existing, not introduced by this PR).
  • I was not able to run dotnet build/dotnet test in this sandbox (the command required interactive approval I couldn't grant), so I relied on static review plus the test files already present under test/Paramore.Darker.Extensions.AspNetCore.Tests/ and test/Paramore.Darker.Extensions.Tests/. The PR description states the full suite passes on net8.0/net9.0 and that two adversarial review passes already ran — if you want, a maintainer/CI run should confirm that holds for the final state of the branch.

Summary

Both stated goals are met by the design and the code backing it: teardown is now isolated, logged, scope-last, and never-throwing (#378/#379), and AddDarkerRequestScope() + HandlerLifetime = Scoped gives Darker handlers genuine access to the ASP.NET Core request's Scoped services, including an EF Core DbContext (#380). The decline-with-warning behavior around ambient scope adoption is conservative and well-tested. I found no correctness issues in the teardown or scope-adoption logic; the main residual risk is the inherent one the PR itself documents (concurrent queries sharing a Scoped handler under JoinAmbient), which is a design tradeoff, not a bug.

@iancooper
iancooper merged commit 86e169c into master Oct 19, 2026
6 checks passed
@iancooper
iancooper deleted the feature/377-di-lifetime-conformance branch October 19, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant