Kanject.Core.Packages

View .md

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 switch statements scattered across services
  • One giant factory that knows every provider in the application
  • IServiceProvider lookups 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

bash
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:

xml
<ItemGroup>  <PackageReference Include="Kanject.Core.Packages" />  <PackageReference Include="Kanject.Core.Packages.Annotations" PrivateAssets="all" /></ItemGroup>

Define the port

csharp
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:

csharp
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

csharp
// 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:

csharp
// 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:

csharp
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:

csharp
// 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>>:

csharp
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:

csharp
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}PackageManager and I{Package}Resolver, plus an Add{Package}Packages() registration per provider assembly
  • A strongly-typed Use{Provider}V{n}Package() method per provider and version, alongside key-based ResolvePackage(key)
  • [ProviderAggregator] for fan-out — one generated call that reaches every listed provider
  • Keyed DI underneath — scoped by default, per-provider Lifetime override, no reflection and Native AOT compatible
  • IPackageScope and PackageScopeBuilder for 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, with KANPKG codes naming what is missing
Was this page helpful?