Skip to content

Container validation, lifetime helpers, and reflection-free adapter chains - #4

Merged
TarasKovalenko merged 2 commits into
mainfrom
net10-upgrade-and-features
Aug 23, 2026
Merged

TarasKovalenko merged 2 commits into
mainfrom
net10-upgrade-and-features

Conversation

@TarasKovalenko

Copy link
Copy Markdown
Owner

This branch finishes the .NET 10 work and takes the library from "fluent registration helpers" to something that also tells you when your container is wrong.

Container validation

ValidateRelayRegistrations() reads the descriptors and reports what is broken before a provider is ever built:

services.ValidateRelayRegistrationsOrThrow();
1 dependency injection issue(s) found:
 - [Error] CaptiveDependency: Singleton ReportScheduler (registered as ReportScheduler) captures
   scoped IUnitOfWork. The scoped instance would live for the lifetime of the application.
   Inject IRelayScopeRunner (or IServiceScopeFactory) and create a scope per unit of work instead.

It catches captive dependencies, missing registrations, singletons holding disposable transients and (opt-in) duplicates, and it understands keyed registrations, open generics, IEnumerable<T>, Lazy<T> and optional constructor parameters. Unlike ValidateOnBuild/ValidateScopes it reports everything at once and never has to resolve anything.

Lifetime helpers

  • IRelayScopeRunner - run a unit of work in a fresh scope from a singleton, which is the actual fix for "cannot consume scoped service from singleton". Registered by AddRelayServices().
  • AddLazyResolution() - Lazy<T> for any registered service: defers expensive construction, breaks constructor cycles, no service locator.

Decorators

Decorate<TInterface, TDecorator>(), TryDecorate and DecorateKeyed. Two behaviour changes here: decorating now wraps every non-keyed registration instead of only the last one, and the non-keyed overloads no longer match keyed descriptors - doing so used to throw, because reading ImplementationType on a keyed descriptor is invalid.

Reflection-free chains, trimming and AOT

Adapter chains used MethodInfo.Invoke per step and MakeGenericType + Activator for typed chain registration. The builders know all the types statically, so the invoker is now built at registration time and execution does no reflection at all. Hand-built steps keep the old path.

That made it possible to mark the package IsAotCompatible/IsTrimmable with real annotations. Assembly scanning and RelayTo(Func<IRelayContext, Type>) carry [RequiresUnreferencedCode] instead of a suppression, so the analyzer tells the truth.

Fixes

  • assembly scanning survives a partially loadable assembly instead of throwing ReflectionTypeLoadException
  • failover reports every relay failure via AggregateException; the parallel strategy no longer hides all but the first
  • AddRelayServices() is idempotent
  • adapters reused across chain steps are registered once
  • DefaultRelayContext honours DOTNET_ENVIRONMENT
  • MultiRelay takes a TimeProvider, so retry backoff is testable

Build and CI

Packages go to artifacts/packages, GeneratePackageOnBuild is off, ContinuousIntegrationBuild only applies on CI. New release.yml publishes on a v* tag or manual dispatch (needs the NUGET_API_KEY secret); CI now runs on Linux, Windows and macOS and packs on every build.

Breaking changes (2.0.0)

  • IMultiRelay<T>.RelayToAll / RelayToAllWithResults take an optional CancellationToken. Call sites are unaffected; custom implementations need updating.
  • Decorate<TInterface>(...) wraps every non-keyed registration and skips keyed ones.

324 tests pass, all 18 examples run, build is clean with warnings-as-errors and the trim/AOT analyzers enabled.

Three of the most common dependency injection mistakes now have an answer
in the library instead of a runtime surprise:

- ValidateRelayRegistrations() statically analyses an IServiceCollection and
  reports captive dependencies, missing registrations, singletons capturing
  disposable transients and duplicate registrations - all of them at once,
  before the provider is built. Keyed registrations, open generics,
  IEnumerable<T>, Lazy<T> and optional constructor parameters are understood.
- IRelayScopeRunner runs work in a fresh scope, so a singleton no longer has
  to capture a scoped service to get its job done.
- AddLazyResolution() enables Lazy<T> for any registered service, which
  defers expensive construction and breaks constructor cycles.

The decorator API grows Decorate<TInterface, TDecorator>(), TryDecorate and
DecorateKeyed. Decorating now wraps every non-keyed registration rather than
only the last one, and keyed descriptors are no longer matched by the
non-keyed overloads - reading ImplementationType on one of those threw.

AddRelayServices() became idempotent; calling it twice used to leave
duplicate IRelayContext and IRelayResolver descriptors behind.
…safe

Chains used to resolve the Adapt method through MethodInfo.Invoke on every
step, and typed chains built their registration with MakeGenericType plus
Activator.CreateInstance. The builders already know the source, target and
adapter types statically, so the invoker is now closed over at registration
time: executing a chain performs no reflection at all. Steps constructed by
hand still fall back to the old path.

With that gone the package is marked IsAotCompatible/IsTrimmable and fully
annotated. The entry points that genuinely cannot be trimmed - assembly
scanning and RelayTo(Func<IRelayContext, Type>) - are annotated with
RequiresUnreferencedCode rather than suppressed.

Also fixed along the way:

- assembly scanning no longer dies with ReflectionTypeLoadException when a
  single type in the assembly fails to load
- failover reports every relay failure through an AggregateException, and
  the parallel strategy surfaces all failures instead of just the first
- MultiRelay takes a TimeProvider (resolved from the container when present),
  so retry backoff is deterministic in tests
- RelayToAll/RelayToAllWithResults accept a CancellationToken
- adapters shared between chain steps are registered once
- DefaultRelayContext falls back to DOTNET_ENVIRONMENT for non-ASP.NET hosts

Packaging: packages go to artifacts/packages, GeneratePackageOnBuild is off
so local builds stay fast, and ContinuousIntegrationBuild only applies on CI.
Adds a release workflow (tag or manual dispatch) and widens CI to a
three-OS matrix that also packs on every run.

Version 2.0.0: IMultiRelay<T> signatures changed and Decorate wraps every
registration now.
@TarasKovalenko
TarasKovalenko merged commit 5807af1 into main Aug 23, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant