Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .agents/skills/mammoth-di/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ Use the application's existing service collection/host and requested lifetimes.

## Establish the applicable contract

Read the consumer's PackageReference/central package versions and resolved assets before assuming these guarantees. This skill describes current develop after the disposal, constructor, snapshot, keyed-generic and DI-cache fixes. They are **unreleased** as of October 1, 2026; latest listed stable release is 0.7.1. Don't assume an unversioned package install includes them. When the app is pinned to older behavior, explain the needed version/fix and respect its upgrade constraints rather than silently changing a major dependency.
Read the consumer's PackageReference/central package versions and resolved assets before assuming these guarantees. This skill describes current develop targeting the next release after published 0.8.0. Constructor-graph restoration (#121), attribute-order fixes (#122) and the vNext changes are **unreleased**; do not assume 0.8.0 includes them. Don't assume an unversioned package install includes them. When the app is pinned to older behavior, explain the needed version/fix and respect its upgrade constraints rather than silently changing a major dependency.

The fixed baseline requires Microsoft DI >=10.0.0 and Bcl.AsyncInterfaces >=10.0.0. DI 9.0.0/9.0.20 can corrupt reused keyed/unkeyed enumeration after native compilation. Use a release containing the upstream fix; fresh providers are not a workaround. Targets remain netstandard2.0/net8/net9/net10, verified on net472 Windows and .NET 8/9/10. netstandard is a compile contract, not a runtime. Adapt example C# syntax to the application's SDK.

Imports: `Microsoft.Extensions.DependencyInjection`, `Mammoth.Extensions.DependencyInjection`; maps also need `Mammoth.Extensions.DependencyInjection.Configuration`; inspectors need `.Inspector`.

## Choose registration and resolution deliberately

- Decorate with `Decorate<TService,TDecorator>()` after the intended registration. It wraps only the last exact service registration, preserving its key/lifetime/order; repeat calls nest outer layers. Container-created inner services and decorators are disposed independently. Never forward decorator disposal to the injected inner; caller-supplied instances stay caller-owned. This closed generic API does not register open-generic decorators.
- Decorate with `Decorate<TService,TDecorator>()` after the intended registration. It wraps only the last exact service registration, preserving its key/lifetime/order; repeat calls nest outer layers. This closed generic API does not register open-generic decorators. Original type registrations retain native graph validation before activation, including rejected constructor candidates and enumerable members; `ValidateOnBuild` may reject them at startup. Factory originals and non-empty maps remain opaque to that inspection.
- **Decorators must not dispose their injected inner service**, through either `Dispose()` or `DisposeAsync()`. DI owns each container-created original and decorator independently. Decorators release only resources they create and own themselves; instance registrations remain caller-owned. Use the [ownership recipe](references/usage.md#keyed-decorators-and-caller-owned-instances) to verify scoped layers and caller ownership.
- Map **parameter names** using `Parameter.ForKey("store").Eq("primary")` or `Dependency.OnValue("label", "batch")`. `Eq` accepts a string key; other native keys can use a suitable attribute constant or an explicit factory. Values must be assignable; maps are not global registrations. Unused names are ignored, so verify names against the constructor.
- Non-empty DependsOn maps select at resolution: one preferred `[ActivatorUtilitiesConstructor]` must be satisfiable; otherwise the unique longest satisfiable public constructor wins. Equal-length ambiguity fails. Named overrides take precedence over explicit-key `[FromKeyedServices(key)]`/`[ServiceKey]` and ordinary services; optional defaults apply only to unregistered dependencies. Use explicit attribute keys on this mapped path; do not assume newer native attribute lookup modes are supported just because DI 10 is referenced. Rejected constructors' dependencies are not created. Empty maps delegate to native DI. Custom providers need keyed resolution plus ordinary/keyed service probes.
- Non-empty DependsOn maps select at resolution: one preferred `[ActivatorUtilitiesConstructor]` must be satisfiable; otherwise the unique longest satisfiable public constructor wins. Equal-length ambiguity fails. Named overrides take precedence over explicit-key `[FromKeyedServices(key)]`/`[ServiceKey]` and ordinary services; optional defaults apply only to unregistered dependencies. `[FromKeyedServices]` inherits the current key; `[FromKeyedServices(null)]` performs unkeyed lookup. `[ServiceKey]` only injects a non-null current key. For both attributes on one untouched parameter, metadata order selects the first effective non-null binding; named maps override both. Rejected constructors' dependencies are not created. Empty maps delegate to native DI. Mapped factories need keyed resolution plus ordinary/keyed service probes. Native original type registrations behind decorators need no separate keyed availability probe; contextual decorator constructors retain their probes.
- Build with `ServiceProviderFactory.CreateServiceProvider(services, options)` when using Mammoth provider queries/discovery. For a host, use its `UseServiceProviderFactory` hook rather than constructing a second provider. Native BuildServiceProvider alone does not create Mammoth's authoritative metadata.

For assembly scanning, constrain `AssemblyInspector` filters and assign maps through `Configure((registration, type) => registration.DependsOn = ...)` before the parameterless `LifestyleSingleton`/Scoped/Transient method. Do not invent a lifestyle overload taking a map.
Expand Down
8 changes: 6 additions & 2 deletions .agents/skills/mammoth-di/references/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,11 @@ public sealed class Scanned(string label) { public string Label { get; } = label

## Keyed decorators and caller-owned instances

Decorate the intended keyed registration immediately, before adding another registration of that service type. Each wrapper releases only its own resources. A caller-owned singleton is explicitly disposed by the caller after the provider releases its wrapper.
> **Decorators must not dispose their injected inner service.** This includes both `Dispose()` and `DisposeAsync()`. DI owns container-created originals and every decorator independently. A decorator releases only resources it creates and owns itself; it never forwards disposal to `Inner`.

Decorate the intended keyed registration immediately, before adding another registration of that service type. Native original type graphs are validated before activation; enabling `ValidateOnBuild` can move graph errors to startup. Factory originals and non-empty maps retain their own activation policies. A caller-supplied singleton registered with the instance overload remains caller-owned and is explicitly disposed by the caller after the provider releases its wrapper. A factory returning that same instance would instead make DI track its disposal.

The recipe below verifies exactly-once disposal for each scoped layer and preservation of caller ownership. Forwarding `Inner.Dispose()` from `StoreDecorator` would break these assertions. For an async decorator, release only its own async resources in `DisposeAsync()` and use async scope/provider disposal when any layer is async-only. The [detailed design](../../../../docs/decorator-architecture.md) includes a complete async scope example and explains the native original registration and scoped holder.

```csharp
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -195,6 +199,6 @@ public sealed class StoreDecorator(IStore inner) : IStore
{
public IStore Inner { get; } = inner;
public int DisposeCount { get; private set; }
public void Dispose() => DisposeCount++; // do not dispose Inner
public void Dispose() => DisposeCount++; // Own cleanup only; never dispose Inner.
}
```
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Bug Fixes

- Restore native constructor graph validation for decorated implementation-type originals, rejecting invalid nested/generic/enumerable/cyclic graphs before dependency factories execute. Preserve original keys, independent registration caches and exactly-once disposal; keep scoped caching correct after native compilation [#121](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/121).

- Preserve native metadata order when FromKeyedServices and ServiceKey share a constructor parameter. Keep named DependsOn overrides first, respect effective null/inherited keys, and apply the same binding in selection and resolution without additional service lookups [#122](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/122).

- Restore original type activation through decorators when a provider exposes ordinary availability probing and keyed resolution without a keyed availability probe. Require keyed probing only for non-null dependency lookup keys, while preserving native constructor rules, generic constraint checks, layer lifetimes and disposal ownership [#120](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/120).
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ Import `Microsoft.Extensions.DependencyInjection` and `Mammoth.Extensions.Depend

### Decorator

Use the `Decorator` extension to wrap an existing service with a new implementation without altering the original.
Use `Decorate<TService, TDecorator>()` to wrap an existing service with a decorator.

> **Decorators must not dispose their injected inner service.** Never forward `Dispose()` or `DisposeAsync()` to the inner service. DI disposes every container-created original and decorator independently; instances supplied through an instance registration remain caller-owned. A decorator releases only resources it creates and owns itself.

See [decorator disposal and ownership](#decorator-disposal-and-ownership) for a safe implementation, and [the detailed design](docs/decorator-architecture.md) for native activation, scoped holders, factory layers, and the reasons for this registration strategy.

Both interface-based and class-based services can be decorated. The following examples demonstrate how to decorate services.

Expand Down Expand Up @@ -99,6 +103,8 @@ services.Decorate<ConcreteService, ConcreteServiceDecorator>();

Decorators preserve Singleton, Scoped and Transient lifetimes and native keys for type, instance and factory registrations. `Decorate<TService, TDecorator>()` wraps only the **last exact service-type registration**, retaining its position. Repeated calls add outer layers; this API does not register open-generic decorators.

Original implementation-type registrations retain native constructor graph planning: invalid nested, generic, enumerable or cyclic graphs fail before dependency factories run. `ValidateOnBuild` can reject them at startup, including inherited-key dependencies validated under `AnyKey`. Factory registrations and non-empty dependency maps remain opaque to native graph inspection. See [the registration design](docs/decorator-architecture.md).

Each occurrence in the collection is a separate registration, even when the same `ServiceDescriptor` instance is added more than once. Only the last occurrence is decorated:

```csharp
Expand All @@ -123,7 +129,30 @@ using var scope = provider.CreateScope();
var decorated = scope.ServiceProvider.GetRequiredKeyedService<ITestService>("one");
```

Container-created inner services and every decorator are disposed independently. A disposable decorator must dispose its own resources without forwarding disposal to its injected inner service. Caller-supplied instances remain caller-owned. See the [complete ownership example](.agents/skills/mammoth-di/references/usage.md#keyed-decorators-and-caller-owned-instances).
#### Decorator disposal and ownership

**Decorators must not dispose their injected inner service**, including through `DisposeAsync()`. DI already owns container-created inner services and every decorator. Forwarding disposal can dispose an inner layer twice; for caller-supplied instances, it violates caller ownership. A decorator that owns no resources does not need to implement `IDisposable` or `IAsyncDisposable` merely because its inner service does.

This decorator disposes its own buffer only:

```csharp
public interface IBufferedService { void Run(); }

public sealed class BufferedServiceDecorator(IBufferedService inner)
: IBufferedService, IDisposable, IAsyncDisposable
{
private readonly MemoryStream _buffer = new(); // Owned by this decorator.
public void Run() { _buffer.WriteByte(1); inner.Run(); }
public void Dispose() => _buffer.Dispose(); // Never dispose inner here.
public ValueTask DisposeAsync()
{
Dispose(); // Release only the owned buffer; do not call inner.DisposeAsync().
return default;
}
}
```

Dispose the owning scope/provider to release container-owned layers. Use async scope/provider disposal for async-only resources. For `AddSingleton<IService>(existingInstance)` or its keyed instance overload, the caller remains responsible for `existingInstance`; a factory returning an object gives DI ownership of its result. See the [complete keyed ownership example](.agents/skills/mammoth-di/references/usage.md#keyed-decorators-and-caller-owned-instances) and the [design's runnable scoped example](docs/decorator-architecture.md#the-ownership-rule).

### DependsOn (requires Keyed Services support)

Expand Down Expand Up @@ -187,7 +216,7 @@ The current design is limited to some common use case and it's very similar to t

This extension supports Singleton, Scoped, Transient and Keyed registrations. Maps match constructor **parameter names**, not service types; `Eq` accepts string keys and mapped values must be assignable. Unused map names are ignored, so check names carefully.

Non-empty maps choose a constructor at resolution time. A single `[ActivatorUtilitiesConstructor]` constructor must be satisfiable; otherwise the unique longest satisfiable public constructor wins. Equal-length ambiguity fails. Named overrides take precedence over explicit-key `[FromKeyedServices(key)]`, `[ServiceKey]` and ordinary injection; optional defaults apply only to unregistered dependencies, including null and non-null nullable-enum defaults. Rejected constructors do not create dependencies. Empty maps use native DI behavior. Custom providers need ordinary/keyed service probes and keyed resolution. Use explicit keys on the mapped attribute path rather than assuming newer native attribute lookup modes are supported.
Non-empty maps choose a constructor at resolution time. A single `[ActivatorUtilitiesConstructor]` constructor must be satisfiable; otherwise the unique longest satisfiable public constructor wins. Equal-length ambiguity fails. Named overrides take precedence over explicit-key `[FromKeyedServices(key)]`, `[ServiceKey]` and ordinary injection; optional defaults apply only to unregistered dependencies, including null and non-null nullable-enum defaults. Rejected constructors do not create dependencies. Empty maps use native DI behavior. Mapped activation needs ordinary/keyed service probes and keyed resolution. `[FromKeyedServices]` inherits the current key; `[FromKeyedServices(null)]` uses unkeyed lookup. With `AnyKey` registrations, resolve a concrete key when inheriting dependencies. Ordinary decoration needs no keyed availability probe for native original type activation; contextual decorator constructors and mapped factories keep their probe requirements.

`[ServiceKey]` injects only a non-null service key. In unkeyed or null-key registrations it is ignored, allowing another effective keyed attribute, an ordinary service or an optional default to bind the parameter. When both `[FromKeyedServices]` and `[ServiceKey]` appear on an untouched parameter, metadata order determines the first effective non-null key binding; a null-key lookup does not suppress a later `[ServiceKey]`. Named overrides still take precedence.

Expand Down
Loading