Kanject.Core.Packages
The strategy pattern, generated. When a port can have more than one implementation — Stripe or Paystack behind IPaymentGateway, one carrier or another behind IOrderShipping — you declare the interface and tag the providers, and the source generator emits the resolver, the manager, and the keyed DI registrations. Adding a provider is adding one class. Free under the Community licence.
It replaces the shapes multi-provider code usually decays into:
- Hand-written
switchstatements scattered across services - One giant factory that knows every provider in the application
IServiceProviderlookups with string keys at business call sites- Host-specific plugin glue that libraries cannot reuse
The provider registry stays source-generated — no reflection, no assembly scanning, Native AOT compatible — while the business rule for which provider stays in your code.
Install
dotnet add package Kanject.Core.Packagesdotnet add package Kanject.Core.Packages.Annotations Add both to the project that declares the interface and to every project that declares a provider for it:
<ItemGroup> <PackageReference Include="Kanject.Core.Packages" /> <PackageReference Include="Kanject.Core.Packages.Annotations" PrivateAssets="all" /></ItemGroup> Define the port
using Kanject.Core.Packages.Annotations.Attributes;// The port. The package id names everything the generator emits:// "payment.gateway" → IPaymentGatewayPackageManager, IPaymentGatewayResolver.[Package(id: "payment.gateway")][PackageDescription("Process order payments through third-party gateways")]public interface IPaymentGateway{ Task<PurchaseOrderResponse> PurchaseOrderAsync( PurchaseOrderRequest request, Guid userId); Task ProcessWebhookAsync(WebhookEvent webhookEvent);} Tag the providers
Each implementation names the port it serves, its own id and version, and a short resolver key. Make the class partial so the generator can add its metadata properties. [DefaultPackageProvider] marks the fallback:
using Kanject.Core.Packages.Annotations.Attributes;[Package<IPaymentGateway>][PackageProvider(id: "stripe.payment.gateway", version: 1)][PackageResolverKey("stripe")][DefaultPackageProvider]public sealed partial class StripePaymentGateway : IPaymentGateway{ // implementation}[Package<IPaymentGateway>][PackageProvider(id: "paystack.payment.gateway", version: 1)][PackageResolverKey("paystack")]public sealed partial class PaystackPaymentGateway : IPaymentGateway{ // implementation} Register
// Keyed providers, manifests, the manager, and the key resolver.services.AddPaymentGatewayPackages(); The extension is generated next to the providers, in a namespace named after their assembly. Provider assemblies compose through a container-local catalog, so sibling assemblies that never reference each other still resolve together.
Pick a provider
Two ways, over the same resolver and the same keyed registrations. Use the generated manager when the call site intentionally chooses — webhooks, admin tools, tests:
// Explicit: the call site names the provider.public sealed class PaymentWebhookHandler( IPaymentGatewayPackageManager paymentGateways){ // A generated, strongly-typed method per provider and version… public Task HandleStripeAsync(WebhookEvent webhookEvent) => paymentGateways .UseStripePaymentGatewayV1Package() .ProcessWebhookAsync(webhookEvent); // …or dispatch on data: a key from a route, a tenant, a config row. public Task HandleAsync(string gatewayKey, WebhookEvent webhookEvent) => paymentGateways .ResolvePackage(gatewayKey) .ProcessWebhookAsync(webhookEvent);} Or set an IPackageScope for the unit of work and inject the port itself. With no scope registered, the port resolves to the default provider:
using Kanject.Core.Packages.Engine;using Kanject.Core.Packages.Interfaces;// Ambient: choose once for the unit of work…var packageScope = PackageScopeBuilder.Create() .Use<IPaymentGateway>("paystack") .Build();services.AddScoped<IPackageScope>(_ => packageScope);// …and business code injects the bare port. No provider names here.public sealed class CheckoutWorkflow(IPaymentGateway paymentGateway){ public Task RunAsync(Guid orderId, Guid userId) => paymentGateway.PurchaseOrderAsync( new PurchaseOrderRequest { OrderId = orderId }, userId);} A scope can pin a version too — .Use<IPaymentGateway>("stripe", version: 1). Leave the version off and the highest registered version for that key is used.
Put the routing rule in one place
The generated resolver maps [PackageResolverKey] values to providers and needs no code from you. When the choice depends on your own data — the buyer's country, the tenant's plan — replace it with your own. That is the one place a switch survives, and every call site and every scope follows it:
// Your routing rule, in one place. The generator emits the abstract base.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) { // Country → gateway key, from your own configuration. var key = await countrySettings.GetGatewayKeyAsync(args["countryId"]); return key is null ? GetDefaultPackage() : ResolvePackageWith(key, version: null); } // Key → provider. The base class throws until you supply this. 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)}."), };}services.AddPaymentGatewayResolver<PaymentGatewayResolver>(ServiceLifetime.Scoped);services.AddPaymentGatewayPackages(); Fan out to every provider
Some calls should reach all providers rather than one — collecting quotes, broadcasting a notification. Declare an aggregator and the generator writes one method per port method that calls each listed provider and awaits them together; a Task<T> method returns Task<IList<T>>:
using Kanject.Core.Packages.Annotations.Attributes;// Ask every carrier for a quote, in one call.[ProviderAggregator(typeof(IShippingQuotes))][PackageProvider<ExpressCourierQuotes>][PackageProvider<EconomyCourierQuotes>]public partial class ShippingQuoteAggregator{}services.AddShippingQuoteAggregator(); Validate at startup
The analyzers catch static miswiring at compile time, but they cannot see the container — a provider assembly nobody registered, or a config value that selects a key no provider answers to. One opt-in call checks the live graph before the first request:
using Kanject.Core.Packages.Diagnostics.Validators;var app = services.BuildServiceProvider();app.ValidatePackageGraphOrThrow(); // PackageGraphValidationException on any error What ships with it
[Package(id)]on the interface;[Package<TPackage>],[PackageProvider(id, version)],[PackageResolverKey], and[DefaultPackageProvider]on providers- Generated
I{Package}PackageManagerandI{Package}Resolver, plus anAdd{Package}Packages()registration per provider assembly - A strongly-typed
Use{Provider}V{n}Package()method per provider and version, alongside key-basedResolvePackage(key) [ProviderAggregator]for fan-out — one generated call that reaches every listed provider- Keyed DI underneath — scoped by default, per-provider
Lifetimeoverride, no reflection and Native AOT compatible IPackageScopeandPackageScopeBuilderfor ambient, per-request provider choice with optional version pinning- Side-by-side provider versions — pin one for a staged rollout or a cohort migration, or take the latest registered
- Cross-assembly composition — provider assemblies that never reference each other resolve through one catalog
ValidatePackageGraph()/ValidatePackageGraphOrThrow()— a startup check of the composed container, withKANPKGcodes naming what is missing