Kanject.Core.Packages
3.13.1
Prefix Reserved
dotnet add package Kanject.Core.Packages --version 3.13.1
NuGet\Install-Package Kanject.Core.Packages -Version 3.13.1
<PackageReference Include="Kanject.Core.Packages" Version="3.13.1" />
<PackageVersion Include="Kanject.Core.Packages" Version="3.13.1" />
<PackageReference Include="Kanject.Core.Packages" />
paket add Kanject.Core.Packages --version 3.13.1
#r "nuget: Kanject.Core.Packages, 3.13.1"
#:package Kanject.Core.Packages@3.13.1
#addin nuget:?package=Kanject.Core.Packages&version=3.13.1
#tool nuget:?package=Kanject.Core.Packages&version=3.13.1
Kanject.Core.Packages
Kanject Package Manager — attribute-driven provider dispatch for ports that can have multiple concrete implementations. Declare a package interface, tag one or more providers, and let the source generator emit resolver, manager, keyed DI, metadata, and optional ambient-scope dispatch.
This package is the runtime half: the IPackageScope routing contract, PackageScopeBuilder, the container-local provider catalog, and the startup package-graph validator. The source generator and analyzers ship separately in Kanject.Core.Packages.Annotations.
The package manager is the Kanject answer to the strategy-pattern problem:
IPaymentGateway -> Stripe or Paystack
IOrderShipping -> Evri or Voila
ICloudPlatform -> AWS or Azure
IMigrationApplier -> DSQL or DynamoDB
The key principle is:
Package interface defines the port.
Package provider implements the port.
Resolver maps a key to a provider id/version.
Provider manifest describes one assembly's providers.
Provider catalog composes manifests from every registered assembly.
Keyed DI stores concrete providers.
Package manager gives an explicit dispatch API.
IPackageScope gives an ambient dispatch API for a unit of work.
Installation
dotnet add package Kanject.Core.Packages
dotnet add package Kanject.Core.Packages.Annotations
Then make sure the project file looks like this:
<ItemGroup>
<PackageReference Include="Kanject.Core.Packages" />
<PackageReference Include="Kanject.Core.Packages.Annotations" PrivateAssets="all" />
</ItemGroup>
dotnet add package writes an IncludeAssets line for Kanject.Core.Packages.Annotations that omits compile. Delete that line (keep PrivateAssets="all") — otherwise the attribute types from Kanject.Core.Packages.Annotations.Attributes can be stripped from compilation.
Add both references to the project that declares a [Package] interface and to every project that declares providers for it. The generator emits the manager and resolver next to the interface, and the DI registrations and provider manifests next to each provider.
Targets .NET 8, .NET 9 and .NET 10. The runtime is Native AOT / trimming compatible: dispatch is keyed DI over generated manifests, with no reflection or assembly scanning.
The Problem
Multi-provider code tends to degrade into one of these shapes:
- Hand-written
switchstatements scattered across services. - A single giant factory that knows every provider in the application.
- Direct
IServiceProviderlookups with string keys at business call sites. - Mutable strategy objects where every method must remember to pick a provider first.
- Host-specific plugin glue that cannot be reused by libraries.
Kanject packages keep the provider registry source-generated and AOT-friendly while leaving the business-specific provider choice in application code.
Core Terms
| Term | Meaning |
|---|---|
| Package interface | The port, marked with [Package(id: "...")]. Example: IPaymentGateway. |
| Package provider | A concrete implementation, marked with [Package<TPackage>] and [PackageProvider(id, version)]. |
| Resolver key | A short dispatch key such as "stripe", "paystack", "aws", or "dsql". |
| Resolver | The generated or custom IXxxResolver that maps a key to (packageId, version). |
| Provider manifest | An AOT-safe generated IPackageProviderManifest that describes the providers contributed by one assembly. |
| Provider catalog | The immutable, container-local IPackageProviderCatalog that composes registered manifests and resolves keys across assemblies. |
| Package manager | The generated IXxxPackageManager wrapper that delegates port methods to the active provider. |
| Keyed DI | The underlying MEDI registration, e.g. AddKeyedScoped<TPackage, TProvider>(key). |
| Package scope | An optional ambient IPackageScope that supplies the active PackageScopeSelection (resolver key + optional version pin) per package interface in a MEDI scope. |
| Package scope selection | A readonly record struct returned by IPackageScope.For(Type) carrying the resolver Key and an optional Version pin. |
| Package scope builder | Fluent helper for building a static scope from typed Use<TPackage>(key) / Use<TPackage>(key, version) calls. Use it for app-wide overrides; implement IPackageScope directly for per-request scopes. |
MEDI means Microsoft.Extensions.DependencyInjection.
Basic Usage
1. Define the package interface
using Kanject.Core.Packages.Annotations.Attributes;
[Package(id: "payment.gateway")]
[PackageDescription("Process order payments through third-party gateways")]
public interface IPaymentGateway
{
Task<PurchaseOrderResponse> PurchaseOrderAsync(
PurchaseOrderRequest request,
Guid userId);
Task<HandleBuyerRefundResponse> HandleBuyerRefundAsync(
HandleBuyerRefundRequest request);
Task ProcessWebhookAsync(WebhookEvent webhookEvent);
}
This tells the generator to create the package surface for payment.gateway. Generated type names come from the PascalCased package id — payment.gateway becomes PaymentGateway, so the manager is IPaymentGatewayPackageManager and the resolver is IPaymentGatewayResolver, whatever the interface itself is called.
2. Define providers
using Kanject.Core.Packages.Annotations.Attributes;
[Package<IPaymentGateway>]
[PackageProvider(id: "stripe.payment.gateway", version: 1)]
[PackageProviderOwner("Stripe")]
[PackageDescription("Stripe-backed payment gateway")]
[PackageResolverKey("stripe")]
[DefaultPackageProvider]
public sealed partial class StripePaymentGateway : IPaymentGateway
{
// implementation
}
[Package<IPaymentGateway>]
[PackageProvider(id: "paystack.payment.gateway", version: 1)]
[PackageProviderOwner("Paystack")]
[PackageDescription("Paystack-backed payment gateway")]
[PackageResolverKey("paystack")]
public sealed partial class PaystackPaymentGateway : IPaymentGateway
{
// implementation
}
Provider classes should be partial so the generated metadata properties (PackageIdentifier, PackageName, PackageProviderName, PackageProviderVersion) can be added. A non-partial provider still registers; it just doesn't get those properties.
[DefaultPackageProvider] marks the fallback provider for GetDefaultPackage(). There should be exactly one default when the package needs default dispatch. A package whose providers are all keyed (no default) is legal and composes fine — managers resolve their active Package lazily — but an actual default access (GetDefaultPackage(), or a bare-port/manager resolution with no IPackageScope selection) throws MissingDefaultPackageProviderException (KANPKG057) naming the package id.
When a provider has no [PackageResolverKey], its provider id ("stripe.payment.gateway") is used as the implicit key.
3. Register generated services
services.AddPaymentGatewayPackages();
The generated DI extensions are emitted into a namespace named after the assembly that declares the providers (its assembly name), so add a using for it in the composition root.
The provider-side extension registers:
keyed providers using their declared lifetime
provider manifests and the container-local provider catalog
package manager
auto key resolver, when resolver keys/defaults are present
scope-aware bare-port dispatch (AddPaymentGatewayScopedDispatch)
the startup graph-validation contributor
If no provider carries [PackageResolverKey] or [DefaultPackageProvider], no key resolver is generated — register your own with AddPaymentGatewayResolver<TResolver>() (see Resolver Strategy).
What Gets Generated
For a package interface such as IPaymentGateway, the generator emits:
| Generated artifact | Purpose |
|---|---|
IPaymentGatewayPackageManager |
Explicit dispatch wrapper that also exposes the package methods. |
PaymentGatewayPackageManager |
Runtime manager implementation. |
PaymentGatewayPackageManager<TPackage> |
Typed manager variant (requires TPackage itself to be registered in DI). |
IPaymentGatewayResolver |
Resolver contract. |
AbstractPaymentGatewayResolver |
Base class for custom resolvers. |
PaymentGatewayKeyResolver |
Auto resolver when [PackageResolverKey] or [DefaultPackageProvider] is present. |
per-provider IPackageProviderManifest |
AOT-safe provider identity, resolver key, version, and default metadata contributed to the runtime catalog. |
AddPaymentGatewayResolver<TResolver>(ServiceLifetime) |
Lifetime-aware DI hook for replacing the resolver; defaults to Scoped. |
AddPaymentGatewayKeyResolver(ServiceLifetime) |
Lifetime-aware registration for the generated catalog-backed resolver. |
AddPaymentGatewayPackages() |
Main provider registration entry point. |
AddStripePaymentGatewayV1Package() |
Registers one provider/version (keyed provider, manifest, manager) without the resolver or bare-port bridge. |
AddPaymentGatewayScopedDispatch() |
Direct IPaymentGateway registration driven by IPackageScope. |
GetStripePaymentGatewayV1Package() |
Explicit service-provider lookup for one provider/version. |
UseStripePaymentGatewayV1Package() |
On the manager: switch to one provider/version. On the resolver: return that provider's (packageId, version). |
| metadata partials | Provider id, name, and version properties on each partial provider. |
Provider-specific names are built from the provider id plus the package name: stripe.payment.gateway v1 becomes StripePaymentGatewayV1Package; a provider id that doesn't already contain the package name gets it appended (stripe becomes StripePaymentGatewayV1Package too).
Keyed DI Shape
Concrete providers are registered as keyed services — Scoped by default. Conceptually:
services.AddKeyedScoped<IPaymentGateway, StripePaymentGateway>(
"payment.gateway::stripe.payment.gateway::1");
services.AddKeyedScoped<IPaymentGateway, PaystackPaymentGateway>(
"payment.gateway::paystack.payment.gateway::1");
The generated resolver returns:
(packageId: "stripe.payment.gateway", version: 1)
The generated lookup then composes the keyed DI key:
payment.gateway::stripe.payment.gateway::1
This means the concrete provider registry remains explicit and reflection-free.
Provider Lifetime
A provider can opt out of the Scoped default with the Lifetime named argument:
using Kanject.Core.Packages.Annotations.Attributes;
using Kanject.Core.Packages.Annotations.Attributes.Enums;
[Package<IPaymentGateway>]
[PackageProvider(id: "paystack.payment.gateway", version: 1, Lifetime = PackageLifetime.Singleton)]
[PackageResolverKey("paystack")]
public sealed partial class PaystackPaymentGateway : IPaymentGateway
{
// stateless, shareable implementation
}
PackageLifetime has Scoped (default), Singleton, and Transient. Only the keyed provider registration honours it; the package manager and the scope-aware bare-port bridge stay Scoped. KANPKG015 flags a Singleton provider whose constructor injects a Kanject service that is Scoped by default (a generated manager or a [Package] port).
Keyed-Service Key Caching
The keyed-service key for each (packageId, version) pair is built once and cached in a static ConcurrentDictionary on the generated manager class, first populated when the provider is registered. The bare-port bridge, the manager's ResolvePackage* methods, and the GetXxxPackage(serviceProvider, ...) helpers all look up that cached key object instead of formatting a new key string for every resolution.
Resolver Strategy
Resolvers map a dispatch key to a package id/version.
The auto resolver is generated from [PackageResolverKey]. In DI, it resolves through the container-local IPackageProviderCatalog, so provider assemblies compose even when they are siblings and do not reference one another. Each provider assembly only needs to register its generated Add...Packages() or per-provider Add...() extension in the host.
Both the single-key and key+version overloads are supported:
resolver.ResolvePackageWith("stripe") // null version → latest registered stripe
// -> ("stripe.payment.gateway", 2) // (whatever's highest)
resolver.ResolvePackageWith("stripe", 1) // explicit pin
// -> ("stripe.payment.gateway", 1)
resolver.ResolvePackageWith("stripe", 99)
// throws KeyNotFoundException with the available versions listed.
The generated ResolvePackageByKey() switch remains as a local fallback for manually constructed resolvers. It only knows the compiling assembly; normal DI-created resolvers use the composed catalog.
If the package needs application-specific selection, replace the resolver. ICountryPaymentSettings below stands in for your own configuration source:
public sealed class PaymentGatewayResolver(
ICountryPaymentSettings countrySettings)
: AbstractPaymentGatewayResolver
{
public override (string packageId, int version) GetDefaultPackage()
=> this.UseStripePaymentGatewayV1Package();
public override async Task<(string packageId, int version)> ResolvePackageWithAsync(
Dictionary<string, string> args)
{
// Example: country id -> checkout provider key from configuration.
var key = await countrySettings.GetGatewayKeyAsync(args["countryId"]);
return key is null ? GetDefaultPackage() : ResolvePackageWith(key, version: null);
}
public override (string packageId, int version) ResolvePackageWith(
string key,
int? version)
=> key switch
{
"stripe" when version is null or 1 => this.UseStripePaymentGatewayV1Package(),
"paystack" when version is null or 1 => this.UsePaystackPaymentGatewayV1Package(),
_ => throw new KeyNotFoundException(
$"No provider matches key '{key}' for {nameof(IPaymentGateway)}.")
};
}
Then register:
services.AddPaymentGatewayResolver<PaymentGatewayResolver>(
ServiceLifetime.Scoped);
services.AddPaymentGatewayPackages();
Resolver Lifetime
Resolver lifetime is selected explicitly at the composition root. Scoped remains the default and is the right choice when a custom resolver uses request/tenant state, repositories, a database context, or any other scoped dependency:
services.AddPaymentGatewayResolver<PaymentGatewayResolver>(
ServiceLifetime.Scoped);
A stateless resolver that depends only on singleton-safe services can be shared:
services.AddPaymentGatewayResolver<CatalogPaymentGatewayResolver>(
ServiceLifetime.Singleton);
Transient is supported for completeness:
services.AddPaymentGatewayResolver<PaymentGatewayResolver>(
ServiceLifetime.Transient);
The scoped manager and bare-port dispatch bridge each capture their resolver when activated, so a Transient resolver generally behaves like one instance per manager/bridge activation. Direct IPaymentGatewayResolver resolutions still receive a new instance every time.
The generated catalog-backed resolver can also be configured directly:
services.AddPaymentGatewayKeyResolver(ServiceLifetime.Singleton);
services.AddPaymentGatewayPackages();
Explicit custom or generated-resolver registration is authoritative regardless of whether it appears before or after AddPaymentGatewayPackages(). Package registration itself only adds the default Scoped resolver when no resolver has already been selected.
KANPKG016 reports the statically knowable captive-dependency case: a custom resolver registered Singleton while injecting a generated package manager or a scope-aware package port. Arbitrary external scoped dependencies cannot be inferred from source alone, so enable MEDI validation as the runtime backstop:
var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
ASP.NET Core supplies a DI scope per request. Desktop, CLI, queue, and worker hosts should create one scope per unit of work:
await using var operationScope = rootProvider.CreateAsyncScope();
var manager = operationScope.ServiceProvider
.GetRequiredService<IPaymentGatewayPackageManager>();
var gateway = manager.ResolvePackage("stripe");
await gateway.ProcessWebhookAsync(webhookEvent);
This MEDI lifetime scope is separate from IPackageScope. Register IPackageScope only when bare-port injection needs ambient key/version routing; explicit manager.ResolvePackage(key) does not require one.
Async Selection: Do the I/O at Scope Construction Time
The scoped-dispatch bridge calls ResolvePackageWith(key, version) synchronously — fine for sync resolvers, but it blocks await from inside the dispatch closure. This is by design, not a missing feature. MEDI's IServiceProvider factory cannot be async, and threading an async story through every port resolution would cost more in complexity than it buys back.
The recommended pattern for async-dependent selection (DB lookup, feature-flag service, remote config, etc.):
1. Do the async work at scope-construction time
2. Store the resolved key/version on a synchronous IPackageScope
3. Bridge reads the pre-computed key/version when the port is resolved
This is exactly what OrderPackageScopes.CreateAsync (below) does — load the order async, then build a sync scope from the result. The unit-of-work boundary owns the async work; the inner code stays clean.
When provider selection genuinely cannot be pre-computed (rare — usually means the unit-of-work boundary is wrong), use the manager's stateless ResolvePackageAsync(args) instead of bare-port injection. Custom resolvers that only implement the async path remain valid for explicit manager calls; they simply don't participate in bare-port injection.
Explicit Dispatch With Package Managers
Package managers are useful when the call site intentionally chooses or switches provider.
public sealed class PaymentWebhookHandler(
IPaymentGatewayPackageManager paymentGateways)
{
public Task HandleStripeAsync(WebhookEvent webhookEvent)
=> paymentGateways
.UseStripePaymentGatewayV1Package()
.ProcessWebhookAsync(webhookEvent);
public Task HandlePaystackAsync(WebhookEvent webhookEvent)
=> paymentGateways
.UsePaystackPaymentGatewayV1Package()
.ProcessWebhookAsync(webhookEvent);
}
Or with resolver keys:
var paystack = paymentGateways.ResolvePackage("paystack");
await paystack.PurchaseOrderAsync(request, userId);
ResolvePackage(...) and ResolvePackageAsync(...) return the selected provider without changing paymentGateways.Package. Prefer them for webhooks, admin tools, diagnostics, tests, concurrent work, and other explicit one-off dispatch flows.
ResolvePackageWith(...) and ResolvePackageWithAsync(...) remain for compatibility. They mutate the manager's active Package and return the manager for fluent delegation, so use them only in sequential scoped code that intentionally changes manager state:
paymentGateways.ResolvePackageWith("paystack");
await paymentGateways.PurchaseOrderAsync(request, userId);
Ambient Dispatch With IPackageScope
IPackageScope is for workflow context. A single MEDI scope can hold package choices for multiple package interfaces:
IPaymentGateway -> paystack
IOrderShipping -> evri
IOrderAddressing -> default
Build a scope with PackageScopeBuilder:
using Kanject.Core.Packages.Engine;
using Kanject.Core.Packages.Interfaces;
var packageScope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("paystack")
.Use<IOrderShipping>("evri")
.Build();
Version pinning is optional:
var packageScope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("stripe", version: 1)
.Build();
Register the scope in the MEDI scope that represents the unit of work:
services.AddScoped<IPackageScope>(_ => packageScope);
After that, direct package injection can work:
public sealed class CheckoutWorkflow(
IPaymentGateway paymentGateway,
IOrderShipping shipping)
{
public async Task RunAsync(Guid orderId, Guid userId)
{
await paymentGateway.PurchaseOrderAsync(
new PurchaseOrderRequest { OrderId = orderId },
userId);
await shipping.CreateShippingLabelAsync(
new CreateShippingLabelRequest(orderId));
}
}
The generated direct registration performs:
IPackageScope.For(typeof(TPackage)) // single Type-based lookup, returns PackageScopeSelection?
-> resolver.ResolvePackageWith(selection.Key, selection.Version)
-> GetRequiredKeyedService<TPackage>(composed keyed DI key)
The contract is intentionally Type-based: generator emit and runtime tooling already know the package interface they're resolving for, so a typeof(...) call is the most direct API. Typed call-site sugar — scope.For<TPackage>(), scope.KeyFor<TPackage>(), scope.VersionFor<TPackage>() — lives in PackageScopeExtensions (Kanject.Core.Packages.Extensions) and forwards to the Type-based method. Use whichever reads better at the call site; the underlying lookup is the same.
PackageScopeSelection (Kanject.Core.Packages.Models) is a readonly record struct with two members: Key (the resolver key) and Version (an optional int? pin). Being a value type, returning it from For(...) doesn't allocate; the bridge unwraps it with is { } selection pattern matching.
If no IPackageScope is registered, or For(typeof(TPackage)) returns null, the generator falls back to resolver.GetDefaultPackage(). Note: default and latest are distinct — default returns whichever provider carries [DefaultPackageProvider]; latest (the path taken when scope supplies a key with no version) returns the highest registered version for that key. These can be different providers in multi-version setups.
Bare-Port and Manager Resolve Consistently
Inside the same MEDI scope, bare-port injection (IPaymentGateway) and manager injection (IPaymentGatewayPackageManager.Package) resolve to the same concrete provider:
public sealed class CheckoutWorkflow(
IPaymentGateway directGateway,
IPaymentGatewayPackageManager managerGateway)
{
public Task RunAsync(...)
{
// Both directGateway and managerGateway.Package point at the same
// provider chosen by IPackageScope.For(typeof(IPaymentGateway)).
// Explicit `managerGateway.ResolvePackageWith(...)` still overrides
// per call when needed.
...
}
}
The manager resolves its active Package lazily, on first access, by reading the ambient scope — the same selection rule as the bridge, so a service taking both injection styles gets the same provider inside a unit of work. Because nothing resolves at construction, a manager over a keyed-only package (no [DefaultPackageProvider]) activates fine; only an actual default-path access can throw.
Where PackageScopeBuilder Should Live
The builder belongs at the business boundary where provider choice is known.
For an order service, keep it behind an application-owned policy object. IOrderRepository and IProviderRoutingTable are your own types:
public sealed class OrderPackageScopes(
IOrderRepository orders,
IProviderRoutingTable routing)
{
public async Task<IPackageScope> CreateAsync(Guid orderId, CancellationToken ct)
{
var order = await orders.GetAsync(orderId, ct)
?? throw new InvalidOperationException("Order not found.");
var builder = PackageScopeBuilder.Create();
if (await routing.PaymentKeyForCountryAsync(order.CountryId, ct) is { } paymentKey)
builder.Use<IPaymentGateway>(paymentKey);
if (await routing.ShippingKeyForProviderAsync(order.ShippingProviderId, ct) is { } shippingKey)
builder.Use<IOrderShipping>(shippingKey);
return builder.Build();
}
}
MEDI can't add registrations to a scope that already exists, so a common way to hand the computed scope to the unit of work is a small scoped holder that implements IPackageScope:
public sealed class AmbientPackageScope : IPackageScope
{
public IPackageScope? Current { get; set; }
public PackageScopeSelection? For(Type packageType) => Current?.For(packageType);
}
services.AddScoped<AmbientPackageScope>();
services.AddScoped<IPackageScope>(sp => sp.GetRequiredService<AmbientPackageScope>());
// At the workflow boundary:
await using var scope = rootProvider.CreateAsyncScope();
scope.ServiceProvider.GetRequiredService<AmbientPackageScope>().Current =
await orderPackageScopes.CreateAsync(orderId, ct);
// Set Current before resolving anything that injects a package port.
var workflow = scope.ServiceProvider.GetRequiredService<CheckoutWorkflow>();
The rule is:
Builder records explicit overrides.
Resolvers own defaults.
Generated DI composes both.
Business workflows use ordinary constructor injection.
Manager vs IPackageScope
Both APIs stay useful.
Use package managers when provider selection is the action:
var stripe = paymentGateways.ResolvePackage("stripe");
await stripe.ProcessWebhookAsync(webhookEvent);
Use IPackageScope when provider selection is context:
This order uses Paystack for payment and Evri for shipping.
Everything resolved inside this order workflow should follow that.
In practice:
| Scenario | Prefer |
|---|---|
Payment webhook route /stripe |
Package manager explicit override |
| CLI command whose provider comes from a config file | Package scope around command execution |
| Order lifecycle using payment + shipping + addressing | Package scope |
| Admin tool comparing providers | Package manager manual switching |
| Tests forcing one provider | Package scope or manager, depending on test shape |
Multiple Package Interfaces in One Project
One project can declare and consume many packages. A single IPackageScope is a map from package type to resolver key:
var scope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("paystack")
.Use<IOrderShipping>("evri")
.Build();
Each generated bridge reads only its own port type:
IPaymentGateway bridge reads scope.For(typeof(IPaymentGateway))
IOrderShipping bridge reads scope.For(typeof(IOrderShipping))
IOrderAddressing bridge reads scope.For(typeof(IOrderAddressing))
Ports omitted from the scope use their resolver defaults.
Defaults and Fallbacks
Defaults belong in resolvers, not in PackageScopeBuilder.
Good:
var scope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("paystack")
.Build();
If IOrderShipping should default to Voila, let IOrderShippingResolver.GetDefaultPackage() say that (or mark the Voila provider [DefaultPackageProvider]). Do not put "voila" into every scope just because it is the current default.
Version Selection
Providers are versioned:
[PackageProvider(id: "stripe.payment.gateway", version: 1)]
When a scope supplies only a key:
.Use<IPaymentGateway>("stripe")
the resolver chooses the latest registered version for "stripe".
When a scope supplies a key and version:
.Use<IPaymentGateway>("stripe", version: 1)
the resolver chooses exactly that version or throws a KeyNotFoundException listing the available versions.
Two providers may share a resolver key as long as their versions differ (KANPKG012 enforces unique (key, version) pairs). Use version pinning for staged rollouts, backwards compatibility, or cohort-specific provider migrations.
Fan-Out With Provider Aggregators
When a call should go to every provider rather than one — broadcasting a notification, collecting quotes — declare an aggregator:
using Kanject.Core.Packages.Annotations.Attributes;
[ProviderAggregator(typeof(IShippingQuotes))]
[PackageProvider<EvriShippingQuotes>]
[PackageProvider<VoilaShippingQuotes>]
public partial class ShippingQuoteAggregator
{
}
The generator gives the class an IServiceProvider constructor and one method per port method that calls each listed provider and awaits them together; with several providers a Task<T> method returns Task<IList<T>>, and collection results are flattened. It also emits AddShippingQuoteAggregator() to register the class Scoped. The providers themselves must be registered (e.g. via their Add...Packages() call). Only Task / Task<T> methods can be fanned out; properties, other return types, generic methods, and by-reference or ref-struct parameters are left out and reported as KANPKG018. See the Kanject.Core.Packages.Annotations package for the exact return-shape rules.
Testing Patterns
Force a provider through scope:
var packageScope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("paystack")
.Build();
services.AddScoped<IPackageScope>(_ => packageScope);
using var scope = services.BuildServiceProvider().CreateScope();
var paymentGateway = scope.ServiceProvider.GetRequiredService<IPaymentGateway>();
Mock IPackageScope directly — a single Type-based method makes this trivial without a framework:
using Kanject.Core.Packages.Interfaces;
using Kanject.Core.Packages.Models;
public sealed class TestPackageScope(IDictionary<Type, PackageScopeSelection?> overrides) : IPackageScope
{
public PackageScopeSelection? For(Type packageType)
=> overrides.TryGetValue(packageType, out var selection) ? selection : null;
}
// In a test:
var scope = new TestPackageScope(new Dictionary<Type, PackageScopeSelection?>
{
[typeof(IPaymentGateway)] = new("stripe", 1),
[typeof(IOrderShipping)] = new("evri"),
});
services.AddScoped<IPackageScope>(_ => scope);
Override direct package injection entirely:
services.AddScoped<IPaymentGateway>(_ => new FakePaymentGateway());
services.AddPaymentGatewayPackages(); // generated TryAddScoped bridge will not replace the fake
The generated direct package bridge uses TryAddScoped<TPackage>, so consumer registrations win.
Startup Graph Validation
KANPKG analyzers catch static miswiring at compile time.
They cannot see the DI graph — whether AddXxxPackages() was actually
called, whether a config-driven scope key resolves, whether a provider
assembly was forgotten, or whether sibling assemblies declare conflicting
keys/defaults. Those faults only become knowable after the host composes its
container.
IServiceProvider.ValidatePackageGraph() closes that gap. Each
AddXxxPackages() registers a generated, per-package
IPackageGraphContributor; the validator collects every registered
contributor (so the union validates the union — including cross-assembly
providers) and checks, against the live container:
- the resolver is registered (KANPKG050)
- every declared provider resolves from keyed DI (KANPKG051)
- the [DefaultPackageProvider] resolves (KANPKG052)
- any IPackageScope-routed key resolves (KANPKG053)
- no duplicate (resolver key, version) exists (KANPKG054)
- no more than one default exists across assemblies (KANPKG055)
- every declared resolver key routes to its provider (KANPKG056)
Discovery is "what's registered as IPackageGraphContributor" — no
reflection scanning, so it stays AOT-clean. Validation runs inside a fresh
scope and actually resolves each declared provider, so provider constructors
run during the check.
Fail fast at host startup:
using Kanject.Core.Packages.Diagnostics.Validators;
var app = services.BuildServiceProvider();
app.ValidatePackageGraphOrThrow(); // throws PackageGraphValidationException on any error
Or inspect without throwing (e.g. from a doctor command):
var report = app.ValidatePackageGraph();
if (report.HasErrors)
Console.Error.WriteLine(report.Describe());
This is the startup-time catch for config-driven routing — e.g. a
configuration value selects "azure" but no Azure provider is installed; the
key routes through IPackageScope, and KANPKG053 names it before the first
request instead of mid-operation. The check is opt-in: nothing calls it
automatically.
Contributors are generator-emitted, so only providers compiled with
Kanject.Core.Packages.Annotations contribute to the report.
Runtime diagnostic codes
These ids appear on PackageGraphIssue.Code (constants on PackageGraphCodes) and in exception messages.
| Code | Meaning |
|---|---|
| KANPKG050 | No IXxxResolver is registered for a package that has providers. |
| KANPKG051 | A declared provider does not resolve from keyed DI. |
| KANPKG052 | The [DefaultPackageProvider] does not resolve. |
| KANPKG053 | A registered IPackageScope routes the port to a key/version with no registered provider. |
| KANPKG054 | Two provider assemblies declare the same resolver key and version for one package. |
| KANPKG055 | More than one provider assembly declares a default provider for the same package. |
| KANPKG056 | A provider resolves from keyed DI, but its resolver key does not route back to it. |
| KANPKG057 | Default resolution was requested for a package with no [DefaultPackageProvider] (thrown as MissingDefaultPackageProviderException). |
Public Surface at a Glance
| Type / member | Namespace | Purpose |
|---|---|---|
IPackageScope |
Kanject.Core.Packages.Interfaces |
Ambient routing contract: PackageScopeSelection? For(Type). |
PackageScopeSelection |
Kanject.Core.Packages.Models |
readonly record struct (string Key, int? Version = null). |
PackageScopeBuilder |
Kanject.Core.Packages.Engine |
Create(), Use<T>(key), Use<T>(key, version), Build(); produces an immutable scope. |
PackageScopeExtensions |
Kanject.Core.Packages.Extensions |
For<T>(), KeyFor<T>(), VersionFor<T>() over IPackageScope. |
IPackageProviderCatalog / PackageProviderCatalog |
Kanject.Core.Packages.Interfaces / .Engine |
Container-local index of provider manifests: Resolve, GetDefault, GetProviders, Validate. |
AddPackageProviderCatalog() |
Kanject.Core.Packages.Extensions |
Registers the catalog once per container (generated registrations call it). |
PackageProviderServiceKeys |
Kanject.Core.Packages.Engine |
Get(packageId, providerId, version): the interned keyed-DI key for a provider of a contract whose assembly doesn't run the generator (generated code calls it). |
IPackageProviderManifest / PackageProviderDescriptor |
Kanject.Core.Packages.Interfaces / .Models |
One assembly's generated provider descriptors. |
PackageGraphValidator |
Kanject.Core.Packages.Diagnostics.Validators |
ValidatePackageGraph() and ValidatePackageGraphOrThrow() on IServiceProvider. |
PackageGraphReport |
Kanject.Core.Packages.Diagnostics |
Issues, IsValid, HasErrors, Errors, Warnings, Describe(). |
PackageGraphIssue / PackageGraphSeverity / PackageGraphCodes |
Kanject.Core.Packages.Diagnostics (+ .Enums) |
One coded issue, its severity, and the code constants. |
IPackageGraphContributor |
Kanject.Core.Packages.Diagnostics.interfaces |
Per-package validation slice (generator-emitted). |
PackageGraphValidationException |
Kanject.Core.Packages.Diagnostics.Exceptions |
Thrown by ValidatePackageGraphOrThrow(); exposes Report. |
MissingDefaultPackageProviderException |
Kanject.Core.Packages.Diagnostics.Exceptions |
KANPKG057; exposes PackageId. |
Gotchas
IPackageScopeis a routing context, not the provider registry. Concrete providers still live in keyed DI.- Scope keys are resolver keys, not always provider ids. If
[PackageResolverKey("stripe")]is used, the key is"stripe". - The contract on
IPackageScopeis a single Type-based method (PackageScopeSelection? For(Type)). The typedFor<T>/KeyFor<T>/VersionFor<T>forms are extension methods over it. Custom implementations only need to implement the one method. - Custom resolvers used with
IPackageScopemust implement the sync key path. Pre-compute async selection at scope-construction time (see the Async Selection section above). - Register
IPackageScopeas scoped for request/order/command-specific routing. For app-wide static overrides built viaPackageScopeBuilder, singleton works too. - Avoid transient
IPackageScope; a new instance per resolution breaks the unit-of-work invariant. - Package managers are best for explicit dispatch. Direct package injection is best for ambient workflow dispatch.
- Defaults should live in
GetDefaultPackage(), not in every scope builder call. defaultandlatestare different. Default =[DefaultPackageProvider]-marked provider; latest = highest registered version for a key. They diverge once you have multi-version providers.- Aggregators fan out to one provider per provider id (the
idin[PackageProvider(id, version)]), latest version wins. Two[PackageProvider]s sharing an id but with different versions are deduped — prevents accidental double-calls in payment-like ports. - Property members on port interfaces are emitted as properties on the manager (read-only as
=> Package.Foo, read-write asget/setdelegation). Parameter modifiers (ref,out,in,ref readonly,params,scoped) and by-ref returns are forwarded as declared. Members inherited from the port's base interfaces are forwarded too (a marker port over a third-party contract gets the whole contract), resolved the way C# member lookup resolves them; indexers, events, and a member declared by more than one base interface (an ambiguous forwarding call) are not, and KANPKG017 (Info) notes each one. Call those on the resolved provider (manager.Package).IDisposable/IAsyncDisposablemembers are deliberately not forwarded: the container owns disposal. PackageScopeBuilderis for static overrides known at scope-construction time. For per-context routing, implementIPackageScopedirectly —Order-style scopes with field-derived keys are the canonical custom-impl pattern.- KANPKG002 fires on every bare-port injection site, by design. The analyzer can't see your DI registration graph at compile time, so it can't distinguish "wired up" from "unwired and will throw at first resolution".
AddXxxPackages()already callsAddXxxScopedDispatch(); the per-providerAdd...V1Package()helpers do not. Read the warning as a checklist prompt — either (a) the composition root registers the bridge (suppress per-site or per-project), or (b) switch to injectingIXxxPackageManagerfor explicit dispatch. The bridge and manager names in the warning are derived from the package id, the same way the generator names them, so they are correct even when the interface name diverges from the id (KANPKG004). - The DI graph is validated at runtime, not compile time. What
KANPKG002and the other analyzers can't see — missing registration, unresolved config-driven keys, a forgotten provider assembly — is exactly whatIServiceProvider.ValidatePackageGraph()checks (see Startup Graph Validation). It's opt-in and runs against the live container; call it at startup (ValidatePackageGraphOrThrow()) or from a diagnostics command. - Sibling provider assemblies are supported. Register each assembly's generated DI extension; its manifest joins the same container-local catalog. Compile-time analyzers catch conflicts visible through references, while KANPKG054/KANPKG055 catch conflicts that only appear when sibling assemblies are composed. This holds even when the
[Package]interface's own assembly doesn't run the generator: providers then get catalog-only registration (no manager, resolver, or default fallback; exact(key, version)resolution only), keyed throughPackageProviderServiceKeysso sibling assemblies still find one another's providers. See theKanject.Core.Packages.Annotationspackage for what that output contains. - Prefer stateless explicit dispatch.
ResolvePackage*()returns a provider and doesn't touch manager state, so it's safe for concurrent explicit dispatch. The legacyResolvePackageWith*()methods mutate the scoped manager'sPackage; do not share that stateful pattern across concurrent operations. - Resolver lifetime belongs to the composition root. Keep request-aware resolvers Scoped; use Singleton only for stateless resolvers whose entire dependency graph is singleton-safe. KANPKG016 covers generator-known captive dependencies, and
ValidateScopescovers the live DI graph.
Mental Model
Explicit path (manager-driven):
provider = manager.ResolvePackage("paystack", version: null)
-> resolver asks the provider catalog for (key, version)
-> catalog composes manifests from all registered provider assemblies
-> manager resolves and returns the keyed provider without changing Package
Ambient path (scope-driven bare-port injection):
IPackageScope.For(typeof(IPaymentGateway)) returns (Key: "paystack", Version: null)
-> generated direct bridge asks resolver for (packageId, version)
-> bridge resolves keyed provider
-> normal DI injects IPaymentGateway
Convergence:
inside the SAME MEDI scope, both paths read the same IPackageScope on activation,
so IPaymentGateway (bare port) and IPaymentGatewayPackageManager.Package both
point at the same provider instance. The manager's explicit ResolvePackageWith(...)
call still changes its active Package when legacy stateful dispatch is intentional.
The two paths share the same resolver, keyed-provider registry, and (when a scope is registered) the same selection rule. That's the point: no parallel dispatch system, just a better DX layer over the existing one.
Related packages
| Package | Role | Availability |
|---|---|---|
Kanject.Core.Packages.Annotations |
Source generator + analyzers that emit managers, resolvers, keyed DI registrations, manifests, and graph contributors | nuget.org |
Kanject.Core.Packages.Annotations.Attributes |
The [Package], [PackageProvider], [ProviderAggregator] … attribute types and PackageLifetime |
nuget.org |
License
Licensed under the Kanject Code Libraries License Agreement (KCLLA); the full text ships in this package as LICENSE.md. Organizations whose trailing-twelve-month gross revenue and total funding raised are each below US$250,000 may use it at no cost under the Free Tier. At or above either threshold a commercial license is required — contact commercial@kanjectbusiness.com.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Kanject.Core.Packages.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 10.0.11)
-
net8.0
- Kanject.Core.Packages.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 8.0.1)
-
net9.0
- Kanject.Core.Packages.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 9.0.18)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.13.1 | 117 | 9/27/2026 |
| 3.13.0 | 86 | 9/27/2026 |
| 3.12.7 | 89 | 9/26/2026 |
| 3.12.6 | 128 | 9/7/2026 |
| 3.12.5 | 101 | 8/27/2026 |
| 3.12.4 | 107 | 8/22/2026 |
| 3.12.3 | 119 | 8/10/2026 |
| 3.12.2 | 111 | 8/9/2026 |
| 3.12.1 | 119 | 8/5/2026 |
| 3.12.0 | 121 | 8/5/2026 |
| 3.11.0 | 116 | 8/3/2026 |
| 3.10.5 | 126 | 7/30/2026 |
| 3.10.4 | 118 | 7/18/2026 |
| 3.10.3 | 125 | 7/13/2026 |
| 3.10.2 | 119 | 7/11/2026 |
| 3.10.1 | 116 | 7/11/2026 |
| 3.10.0 | 145 | 7/9/2026 |
| 3.9.2 | 113 | 7/9/2026 |