Skip to main content

Dependency Injection for AI Services in ASP.NET Core

Intermediate Original .NET 9 Microsoft.Extensions.AI 10.3.0 Microsoft.SemanticKernel 1.54.0
By Rajesh Mishra · Mar 21, 2026 · 13 min read
Verified Mar 2026 .NET 9 Microsoft.Extensions.AI 10.3.0
In 30 Seconds

Register AI services in ASP.NET Core using AddAzureOpenAIChatClient() for IChatClient, AddKernel() for Semantic Kernel, and keyed services for multiple providers. Plugins with dependencies belong in host DI registered with AddSingleton/AddScoped and added to Kernel via AddFromObject(). Mock IChatClient for unit tests. Kernel is registered as Transient — if you need Scoped, register it explicitly via services.AddScoped<Kernel>().

Getting dependency injection right for AI services is one of those things that seems obvious until you hit a captive dependency bug at 2am or discover your plugin’s DbContext is being shared across requests. This guide covers the correct registration patterns for IChatClient, IEmbeddingGenerator, and Semantic Kernel in ASP.NET Core — including keyed services for multiple providers, plugin DI, and a unit test pattern that requires zero live API calls.

The DI Confusion: Two Service Providers

Before diving into registration code, there is one mental model worth locking in: Semantic Kernel has its own internal IServiceProvider, separate from ASP.NET Core’s host IServiceProvider.

When you call kernel.Services.GetService<T>(), you are querying the kernel’s internal container — not the host container. AddKernel() bridges them by constructing the kernel’s service provider from the host’s registrations at kernel resolution time. The practical result: services you register in the host DI via builder.Services.Add*() are available inside the kernel, but the two providers are not the same object.

Host IServiceProvider (ASP.NET Core)
  ├── ILogger, IMemoryCache, HttpClient, IProductRepository ...
  ├── Kernel (transient) — resolved from host, but kernel.Services is a child view
  └── IFunctionInvocationFilter implementations → picked up by AddKernel()

This matters in two places:

  1. Plugin dependencies: Plugins that need DbContext or IHttpClientFactory must be registered in the host DI, not wired up manually inside kernel setup.
  2. Filters: SK filters registered as IFunctionInvocationFilter in the host DI are automatically picked up by AddKernel() — you do not need to add them explicitly to kernel.FunctionInvocationFilters.

Registering IChatClient (MEAI)

Microsoft.Extensions.AI (MEAI) defines IChatClient as the provider-agnostic abstraction for chat completions. For the full comparison of MEAI vs Semantic Kernel vs Agent Framework, see the dedicated guide. The short version: use IChatClient when you want a thin, testable abstraction. Use Kernel when you need plugin orchestration, prompt templates, or filter pipelines.

using Microsoft.Extensions.AI;
using Azure.AI.OpenAI;
using Azure;

// Azure OpenAI
builder.Services.AddAzureOpenAIChatClient(
    new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!),
    new AzureKeyCredential(builder.Configuration["AzureOpenAI:ApiKey"]!));

// Ollama (local dev — swap in without changing service code)
// builder.Services.AddOpenAIChatClient("phi4-mini", new Uri("http://localhost:11434/v1"), "ollama");

AddAzureOpenAIChatClient() registers IChatClient as a singleton. This extension is from the Microsoft.Extensions.AI.AzureAIInference or Microsoft.Extensions.AI packages — check your NuGet references if the method is not available.

Inject and use the client in your service:

public class ChatService(IChatClient chatClient)
{
    public async Task<string> AskAsync(string question, CancellationToken ct = default)
    {
        var result = await chatClient.CompleteAsync(
            [new ChatMessage(ChatRole.User, question)], cancellationToken: ct);
        return result.Message.Text ?? string.Empty;
    }
}

ChatService has no reference to AzureOpenAIClient or any Azure SDK type. Swapping to Ollama, Anthropic, or a mock in tests requires changing only the DI registration.

Register the service:

builder.Services.AddScoped<ChatService>();

Registering Semantic Kernel

AddKernel() is the entry point for the SK DI integration. It returns an IKernelBuilder that you chain to add AI connectors, plugins, and middleware.

builder.Services.AddKernel()
    .AddAzureOpenAIChatCompletion(
        deploymentName: builder.Configuration["AzureOpenAI:Deployment"]!,
        endpoint: builder.Configuration["AzureOpenAI:Endpoint"]!,
        apiKey: builder.Configuration["AzureOpenAI:ApiKey"]!);

AddKernel() registers:

  • Kernel as Transient — each resolution gets a fresh Kernel instance
  • KernelPluginCollection as Singleton — shared across all kernel instances
  • The AI connector you chain (e.g., IChatCompletionService) — picked up by the kernel internally

Important: AddKernel() does not register IChatClient. If you need both a raw IChatClient for simple completions and Kernel for orchestration, register both separately.

Inject and use the kernel:

public class AssistantService(Kernel kernel)
{
    public async Task<string> CompleteAsync(string prompt, CancellationToken ct = default)
    {
        var result = await kernel.InvokePromptAsync(prompt, cancellationToken: ct);
        return result.GetValue<string>() ?? string.Empty;
    }
}

Keyed Services for Multiple Providers

Keyed services (introduced in .NET 8) let you register multiple implementations of the same interface under different string keys. This is the right approach for multi-model routing — e.g., routing simple queries to gpt-4o-mini and complex reasoning to gpt-4o.

using Azure;
using Azure.AI.OpenAI;
using Microsoft.Extensions.AI;

// Create the Azure OpenAI client once — reuse for both deployments
var azureClient = new AzureOpenAIClient(
    new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!),
    new AzureKeyCredential(builder.Configuration["AzureOpenAI:ApiKey"]!));

builder.Services.AddKeyedSingleton<IChatClient>("fast",
    azureClient.GetChatClient("gpt-4o-mini").AsIChatClient());

builder.Services.AddKeyedSingleton<IChatClient>("smart",
    azureClient.GetChatClient("gpt-4o").AsIChatClient());

Inject with the [FromKeyedServices] attribute:

public class ModelRoutingService(
    [FromKeyedServices("fast")] IChatClient fastClient,
    [FromKeyedServices("smart")] IChatClient smartClient)
{
    public async Task<string> CompleteAsync(string query, bool needsReasoning, CancellationToken ct = default)
    {
        var client = needsReasoning ? smartClient : fastClient;
        var result = await client.CompleteAsync(
            [new ChatMessage(ChatRole.User, query)], cancellationToken: ct);
        return result.Message.Text ?? string.Empty;
    }
}

[FromKeyedServices] works in controllers, minimal API handlers, and service constructors. For minimal APIs:

app.MapPost("/chat", async (
    [FromKeyedServices("fast")] IChatClient client,
    ChatRequest request) =>
{
    var result = await client.CompleteAsync([new ChatMessage(ChatRole.User, request.Message)]);
    return Results.Ok(result.Message.Text);
});

Plugin Dependency Injection

Plugins that depend on infrastructure (database, HTTP clients, caches) must be registered in the host DI and then added to the kernel — not constructed manually inside kernel setup.

Stateful Plugins (With Dependencies)

// Plugin with database dependency
public class ProductSearchPlugin(IProductRepository repository)
{
    [KernelFunction("search_products")]
    [Description("Search for products by keyword")]
    public async Task<string> SearchAsync(string keyword) =>
        JsonSerializer.Serialize(await repository.SearchAsync(keyword));
}

// Register plugin and its dependency in host DI
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ProductSearchPlugin>();

Add the plugin to the kernel in a factory or middleware. One clean approach is a Scoped kernel factory:

builder.Services.AddScoped<Kernel>(sp =>
{
    var kernel = sp.GetRequiredService<IKernelBuilder>().Build();
    kernel.Plugins.AddFromObject(sp.GetRequiredService<ProductSearchPlugin>());
    return kernel;
});

Or add the plugin during request handling:

app.MapPost("/assistant", async (Kernel kernel, IServiceProvider sp, AssistantRequest request) =>
{
    var plugin = sp.GetRequiredService<ProductSearchPlugin>();
    kernel.Plugins.AddFromObject(plugin, "Products");
    var result = await kernel.InvokePromptAsync(request.Prompt);
    return Results.Ok(result.GetValue<string>());
});

Stateless Plugins (No Dependencies)

For plugins that only depend on static data or configuration, use AddFromType<T>() directly in the AddKernel() chain:

builder.Services.AddKernel()
    .AddAzureOpenAIChatCompletion(deployment, endpoint, apiKey)
    .Plugins.AddFromType<DateTimePlugin>()
    .Plugins.AddFromType<MathPlugin>();

These plugins are instantiated once (as part of KernelPluginCollection, which is Singleton) and shared across all kernel instances.

IEmbeddingGenerator Registration

IEmbeddingGenerator<TInput, TEmbedding> is the MEAI abstraction for embedding generation. Register it alongside your chat client:

builder.Services.AddAzureOpenAIEmbeddingGenerator(
    new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!),
    new AzureKeyCredential(builder.Configuration["AzureOpenAI:ApiKey"]!));

Inject the typed interface — the generic parameters are load-bearing and prevent accidental mismatches between embedding types:

public class EmbeddingService(IEmbeddingGenerator<string, Embedding<float>> generator)
{
    public async Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
    {
        var result = await generator.GenerateAsync([text], cancellationToken: ct);
        return result[0].Vector.ToArray();
    }
}

Register the service:

builder.Services.AddScoped<EmbeddingService>();

Unit Testing with Mock IChatClient

The most underrated benefit of building against IChatClient is how easy it makes unit testing. No test containers, no recorded HTTP cassettes, no API keys in CI — just a mock that returns the response you specify.

using Microsoft.Extensions.AI;
using Moq;

[Fact]
public async Task ChatService_ReturnsExpectedResponse()
{
    // Arrange
    var mockClient = new Mock<IChatClient>();
    var expectedText = "Hello! How can I help you?";

    mockClient
        .Setup(c => c.CompleteAsync(
            It.IsAny<IList<ChatMessage>>(),
            It.IsAny<ChatOptions?>(),
            It.IsAny<CancellationToken>()))
        .ReturnsAsync(new ChatCompletion(new ChatMessage(ChatRole.Assistant, expectedText)));

    var service = new ChatService(mockClient.Object);

    // Act
    var result = await service.AskAsync("Hello");

    // Assert
    Assert.Equal(expectedText, result);
    mockClient.Verify(c => c.CompleteAsync(
        It.Is<IList<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Hello")),
        It.IsAny<ChatOptions?>(),
        It.IsAny<CancellationToken>()), Times.Once);
}

The Verify call confirms not just that the method was called, but that it was called with a message containing the expected user text. This catches prompt construction bugs without needing a live model.

For testing services that use Kernel, inject a kernel built with a mock IChatCompletionService:

[Fact]
public async Task AssistantService_InvokesPromptSuccessfully()
{
    var mockCompletion = new Mock<IChatCompletionService>();
    mockCompletion
        .Setup(s => s.GetChatMessageContentsAsync(
            It.IsAny<ChatHistory>(), It.IsAny<PromptExecutionSettings?>(),
            It.IsAny<Kernel?>(), It.IsAny<CancellationToken>()))
        .ReturnsAsync([new ChatMessageContent(AuthorRole.Assistant, "Test response")]);

    var kernel = Kernel.CreateBuilder()
        .Services.AddSingleton(mockCompletion.Object)
        .Build();

    var service = new AssistantService(kernel);
    var result = await service.CompleteAsync("What is DI?");

    Assert.Equal("Test response", result);
}

Health Checks for AI Services

Register a health check that probes IChatClient with a minimal request. Map it to your /health or /healthz endpoint:

public class AzureOpenAIHealthCheck(IChatClient chatClient) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        try
        {
            var result = await chatClient.CompleteAsync(
                [new ChatMessage(ChatRole.User, "ping")],
                new ChatOptions { MaxOutputTokens = 1 }, ct);

            return HealthCheckResult.Healthy("Azure OpenAI is reachable");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Azure OpenAI is not reachable", ex);
        }
    }
}

// Registration
builder.Services.AddHealthChecks()
    .AddCheck<AzureOpenAIHealthCheck>("azure-openai");

// Expose the health endpoint
app.MapHealthChecks("/health");

The health check uses MaxOutputTokens = 1 to minimise token cost — you only need to know the endpoint responds, not that it generates meaningful output.

Multi-Tenant AI Service Registration

For SaaS apps where each tenant has their own Azure OpenAI endpoint or deployment, use a Scoped factory:

builder.Services.AddScoped<IChatClient>(sp =>
{
    var tenantId = sp.GetRequiredService<ITenantContext>().TenantId;
    var config = sp.GetRequiredService<IOptionsSnapshot<TenantAIConfig>>().Get(tenantId);

    return new AzureOpenAIClient(
        new Uri(config.Endpoint),
        new AzureKeyCredential(config.ApiKey))
        .GetChatClient(config.Deployment)
        .AsIChatClient();
});

IOptionsSnapshot<T> is Scoped and re-evaluated per request, so configuration changes (e.g., a tenant rotating their API key) take effect without restarting the app. ITenantContext is your Scoped service that resolves the current tenant from the request (JWT claim, subdomain, header, etc.).

Lifetime Summary

ServiceRecommended LifetimeReason
IChatClientSingletonStateless HTTP client, thread-safe
IEmbeddingGeneratorSingletonStateless, same as IChatClient
KernelTransient (default)Fresh state per resolution; plugin collection is Singleton
IFunctionInvocationFilterSingletonRegistered once, wraps all kernel calls
ProductSearchPlugin (with DbContext)ScopedMatches DbContext lifetime
AzureOpenAIHealthCheckTransientInstantiated per health check probe

The lifetime mismatch to watch for: if any Singleton service injects Kernel (which is Transient), the Transient is captured at Singleton construction time and never released — a classic captive dependency. Validate your DI registrations in development with ValidateScopes = true:

builder.Host.UseDefaultServiceProvider(options =>
{
    options.ValidateScopes = true;
    options.ValidateOnBuild = true;
});

ValidateOnBuild = true throws at startup if any captive dependency is detected — before your first request hits production.

Further Reading

For a complete end-to-end example using these DI patterns in a production-ready API, see Build an AI-Powered Minimal API with .NET 9 and Azure OpenAI.

⚠ Production Considerations

  • AddKernel() registers Kernel as Transient by default. This means any singleton service that injects Kernel gets a Transient Kernel captured at singleton lifetime — a captive dependency bug. If you need Kernel in a Singleton, register it explicitly as Singleton: services.AddSingleton<Kernel>() after configuring it.
  • Plugin constructor dependencies must be resolvable from the host DI at kernel resolution time. If a plugin requires a Scoped service (like DbContext) but the Kernel is Transient, the plugin gets a new DbContext per kernel instance — potentially multiple per request. Use AddFromType<T>() carefully with Scoped dependencies.

Enjoying this article?

Get weekly .NET + AI insights delivered to your inbox. No spam.

Subscribe Free →

🧠 Architect’s Note

Design your AI services around IChatClient, not around Kernel or concrete Azure SDK types. This keeps your service layer testable, provider-agnostic, and easier to mock. Only the DI registration wires in the actual AI provider — your business logic never knows whether it is talking to Azure OpenAI or Ollama.

AI-Friendly Summary

Summary

Register AI services in ASP.NET Core using AddAzureOpenAIChatClient() for IChatClient, AddKernel() for Semantic Kernel, and keyed services for multiple providers. Plugins with dependencies belong in host DI registered with AddSingleton/AddScoped and added to Kernel via AddFromObject(). Mock IChatClient for unit tests. Kernel is registered as Transient — if you need Scoped, register it explicitly via services.AddScoped<Kernel>().

Key Takeaways

  • AddKernel() registers Kernel as Transient — inject Kernel not IKernel
  • Multiple AI providers: use keyed services with [FromKeyedServices('key')]
  • Plugins with DB/API deps: register in host DI, add to kernel via AddFromObject()
  • IChatClient is easy to mock — no live API needed for unit tests
  • AddAzureOpenAIChatClient() registers IChatClient; AddKernel() does NOT register IChatClient

Implementation Checklist

  • Add Microsoft.Extensions.AI package for IChatClient
  • Register IChatClient with AddAzureOpenAIChatClient() or AddOllamaChatClient()
  • Register Semantic Kernel with AddKernel().AddAzureOpenAIChatCompletion()
  • Use keyed services for multiple AI providers
  • Register stateful plugins in host DI, stateless plugins with AddFromType<T>()
  • Mock IChatClient in tests — verify prompt content and return controlled responses

Frequently Asked Questions

What does AddKernel() actually register in the DI container?

AddKernel() registers: KernelPluginCollection (singleton), Kernel (transient), ILoggerFactory (if not already registered), and the AI service connectors you chain after AddKernel(). The Kernel instance is transient so each injection gets a fresh Kernel with shared plugins.

How do I register multiple Azure OpenAI deployments in DI?

Use keyed services. Register each ChatClient with a string key: services.AddKeyedSingleton<IChatClient>('fast', fastClient) and services.AddKeyedSingleton<IChatClient>('smart', smartClient). Inject with [FromKeyedServices('fast')] IChatClient client in your controller/service constructor.

What is the difference between Kernel's IServiceProvider and ASP.NET Core's IServiceProvider?

Kernel has its own internal IServiceProvider for resolving plugin dependencies registered during kernel setup. ASP.NET Core's host IServiceProvider is the outer DI container. AddKernel() bridges them — plugins registered in the host DI are available to the kernel via its Services property. They are not the same container instance.

How do I unit test a service that depends on IChatClient?

Mock IChatClient with Moq or NSubstitute. IChatClient.CompleteAsync returns Task<ChatCompletion> — return a mock completion with the text you want. Example: mockClient.Setup(c => c.CompleteAsync(It.IsAny<IList<ChatMessage>>(), null, default)).ReturnsAsync(mockCompletion). No live API calls needed.

How do I inject a Kernel plugin that needs database access?

Register the plugin in host DI: services.AddScoped<ProductSearchPlugin>() with its database dependencies. Then add it to the kernel: in a middleware or factory, call kernel.Plugins.AddFromObject(serviceProvider.GetRequiredService<ProductSearchPlugin>()). Transient kernel means you rebuild plugins each request — use AddFromType<T>() in AddKernel() for stateless plugins.

Can I use IEmbeddingGenerator with DI in ASP.NET Core?

Yes. Register with services.AddAzureOpenAIEmbeddingGenerator(endpoint, apiKey) from Microsoft.Extensions.AI. Inject IEmbeddingGenerator<string, Embedding<float>> in your services. The generic type parameters ensure type safety and allow registering different generators for different embedding types.

How do I implement multi-tenant AI service registration?

Use a factory pattern: services.AddScoped<IChatClient>(sp => { var tenantId = sp.GetRequiredService<ITenantContext>().TenantId; var config = sp.GetRequiredService<IOptionsSnapshot<TenantAIConfig>>().Get(tenantId); return new AzureOpenAIClient(config.Endpoint, config.Credential).GetChatClient(config.Deployment); }). Each request scope gets a tenant-specific IChatClient.

Track your progress through this learning path.

You Might Also Enjoy

Was this article useful?

Feedback is anonymous and helps us improve content quality.

Discussion

Engineering discussion powered by GitHub Discussions.

#Dependency Injection #ASP.NET Core #IChatClient #Semantic Kernel #.NET AI