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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Kanject.Core.Packages" Version="3.13.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kanject.Core.Packages" Version="3.13.1" />
                    
Directory.Packages.props
<PackageReference Include="Kanject.Core.Packages" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Kanject.Core.Packages --version 3.13.1
                    
#r "nuget: Kanject.Core.Packages, 3.13.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Kanject.Core.Packages@3.13.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Kanject.Core.Packages&version=3.13.1
                    
Install as a Cake Addin
#tool nuget:?package=Kanject.Core.Packages&version=3.13.1
                    
Install as a Cake Tool

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:

  1. Hand-written switch statements scattered across services.
  2. A single giant factory that knows every provider in the application.
  3. Direct IServiceProvider lookups with string keys at business call sites.
  4. Mutable strategy objects where every method must remember to pick a provider first.
  5. 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

  1. IPackageScope is a routing context, not the provider registry. Concrete providers still live in keyed DI.
  2. Scope keys are resolver keys, not always provider ids. If [PackageResolverKey("stripe")] is used, the key is "stripe".
  3. The contract on IPackageScope is a single Type-based method (PackageScopeSelection? For(Type)). The typed For<T> / KeyFor<T> / VersionFor<T> forms are extension methods over it. Custom implementations only need to implement the one method.
  4. Custom resolvers used with IPackageScope must implement the sync key path. Pre-compute async selection at scope-construction time (see the Async Selection section above).
  5. Register IPackageScope as scoped for request/order/command-specific routing. For app-wide static overrides built via PackageScopeBuilder, singleton works too.
  6. Avoid transient IPackageScope; a new instance per resolution breaks the unit-of-work invariant.
  7. Package managers are best for explicit dispatch. Direct package injection is best for ambient workflow dispatch.
  8. Defaults should live in GetDefaultPackage(), not in every scope builder call.
  9. default and latest are different. Default = [DefaultPackageProvider]-marked provider; latest = highest registered version for a key. They diverge once you have multi-version providers.
  10. Aggregators fan out to one provider per provider id (the id in [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.
  11. Property members on port interfaces are emitted as properties on the manager (read-only as => Package.Foo, read-write as get/set delegation). 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 / IAsyncDisposable members are deliberately not forwarded: the container owns disposal.
  12. PackageScopeBuilder is for static overrides known at scope-construction time. For per-context routing, implement IPackageScope directly — Order-style scopes with field-derived keys are the canonical custom-impl pattern.
  13. 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 calls AddXxxScopedDispatch(); the per-provider Add...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 injecting IXxxPackageManager for 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).
  14. The DI graph is validated at runtime, not compile time. What KANPKG002 and the other analyzers can't see — missing registration, unresolved config-driven keys, a forgotten provider assembly — is exactly what IServiceProvider.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.
  15. 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 through PackageProviderServiceKeys so sibling assemblies still find one another's providers. See the Kanject.Core.Packages.Annotations package for what that output contains.
  16. Prefer stateless explicit dispatch. ResolvePackage*() returns a provider and doesn't touch manager state, so it's safe for concurrent explicit dispatch. The legacy ResolvePackageWith*() methods mutate the scoped manager's Package; do not share that stateful pattern across concurrent operations.
  17. 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 ValidateScopes covers 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.

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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