n4nAI

Semantic Kernel .NET tutorial: dependency injection setup

A hands-on tutorial for wiring Semantic Kernel into .NET dependency injection, covering kernel registration, plugin injection, multiple kernel scenarios, and verification patterns.

n4n Team3 min read728 words

Audio narration

Coming soon — every post will get a voice note here.

This semantic kernel .net dependency injection tutorial walks you through wiring Semantic Kernel into a .NET application using the built-in DI container. You will register kernels, configure AI services, inject plugins, and verify the setup with runnable code at each step.

Prerequisites

  • .NET 8 SDK or later
  • An OpenAI-compatible API key (or any provider supported by Semantic Kernel)
  • A project targeting net8.0 or net9.0

Create a new console project and add the required packages:

dotnet new console -n SkDiDemo
cd SkDiDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Logging.Console

Minimal kernel registration

Start with the simplest useful configuration: a single kernel backed by OpenAI chat completion. Open Program.cs and replace its contents:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;

var builder = Host.CreateApplicationBuilder(args);

builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Information);

builder.Services.AddKernel()
    .AddOpenAIChatCompletion(
        modelId: "gpt-4o-mini",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-test");

var app = builder.Build();

using var scope = app.Services.CreateScope();
var kernel = scope.ServiceProvider.GetRequiredService<Kernel>();

var result = await kernel.InvokePromptAsync("Say hello in one short sentence.");
Console.WriteLine($"Model response: {result}");

Set your API key and run:

export OPENAI_API_KEY=sk-your-key-here
dotnet run

Expected output (text will vary):

Model response: Hello! How can I help you today?

The AddKernel() extension registers a Kernel instance as a singleton. AddOpenAIChatCompletion adds the chat completion service and binds it to that kernel. This is the foundation every other pattern builds on.

Registering multiple AI services

Real applications often need different models for different tasks — a fast model for classification, a larger model for reasoning, a local model for embeddings. Register each service with a distinct service ID:

builder.Services.AddKernel()
    .AddOpenAIChatCompletion(
        modelId: "gpt-4o-mini",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!,
        serviceId: "fast")
    .AddOpenAIChatCompletion(
        modelId: "gpt-4o",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!,
        serviceId: "reasoning")
    .AddOpenAITextEmbeddingGeneration(
        modelId: "text-embedding-3-small",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!,
        serviceId: "embeddings");

When you need a specific service, request it by ID:

using var scope = app.Services.CreateScope();
var kernel = scope.ServiceProvider.GetRequiredService<Kernel>();

var fastChat = kernel.GetRequiredService<IChatCompletionService>("fast");
var reasoningChat = kernel.GetRequiredService<IChatCompletionService>("reasoning");
var embeddings = kernel.GetRequiredService<ITextEmbeddingGenerationService>("embeddings");

Console.WriteLine($"Fast service: {fastChat.GetType().Name}");
Console.WriteLine($"Reasoning service: {reasoningChat.GetType().Name}");
Console.WriteLine($"Embeddings service: {embeddings.GetType().Name}");

Run again. You should see each service resolved by its registered ID.

Injecting plugins as services

Plugins encapsulate reusable functions. Register them with DI so they can receive their own dependencies — HTTP clients, configuration, database contexts — without the kernel knowing the details.

Create a plugin that fetches a random fact from a public API:

public sealed class FactPlugin
{
    private readonly HttpClient _http;

    public FactPlugin(HttpClient http)
    {
        _http = http;
        _http.BaseAddress = new Uri("https://uselessfacts.jsph.pl/");
    }

    [KernelFunction("get_random_fact")]
    [Description("Returns a random useless fact.")]
    public async Task<string> GetRandomFactAsync(CancellationToken ct = default)
    {
        var response = await _http.GetAsync("api/v2/facts/random", ct);
        response.EnsureSuccessStatusCode();
        var json = await response.Content.ReadAsStringAsync(ct);
        using var doc = System.Text.Json.JsonDocument.Parse(json);
        return doc.RootElement.GetProperty("text").GetString() ?? "No fact found.";
    }
}

Register the plugin and its HttpClient in the DI container:

builder.Services.AddHttpClient<FactPlugin>();
builder.Services.AddKernel()
    .AddOpenAIChatCompletion(
        modelId: "gpt-4o-mini",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!,
        serviceId: "fast")
    .Plugins.AddFromType<FactPlugin>();

Now invoke the plugin through the kernel:

using var scope = app.Services.CreateScope();
var kernel = scope.ServiceProvider.GetRequiredService<Kernel>();

var result = await kernel.InvokeAsync("FactPlugin", "get_random_fact");
Console.WriteLine($"Fact: {result}");

Expected output:

Fact: Honey never spoils. Archaeologists have found pots of honey in ancient Egyptian tombs that are over 3,000 years old and still perfectly edible.

The plugin receives its HttpClient from DI, the kernel discovers the [KernelFunction] methods automatically, and the model can call the function when prompted.

Multiple kernels for isolated contexts

Some architectures require separate kernel instances — for example, one kernel per tenant, or a kernel per request with different plugins enabled. Register a factory instead of a singleton:

builder.Services.AddScoped<Kernel>(sp =>
{
    var kernelBuilder = Kernel.CreateBuilder();
    kernelBuilder.AddOpenAIChatCompletion(
        modelId: "gpt-4o-mini",
        apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY")!);
    
    // Add per-scope plugins or configuration here
    kernelBuilder.Plugins.AddFromType<FactPlugin>();
    
    return kernelBuilder.Build();
});

Now each scope gets its own kernel instance. Demonstrate the isolation:

using (var scope1 = app.Services.CreateScope())
using (var scope2 = app.Services.CreateScope())
{
    var kernel1 = scope1.ServiceProvider.GetRequiredService<Kernel>();
    var kernel2 = scope2.ServiceProvider.GetRequiredService<Kernel>();

    Console.WriteLine($"Kernel 1 hash: {kernel1.GetHashCode()}");
    Console.WriteLine($"Kernel 2 hash: {kernel2.GetHashCode()}");
    Console.WriteLine($"Same instance? {ReferenceEquals(kernel1, kernel2)}");
}

Output confirms separate instances:

Kernel 1 hash: 12345678
Kernel 2 hash: 87654321
Same instance? False

Use AddScoped for per-request kernels in ASP.NET Core, AddTransient for per-injection kernels, or a custom factory for more complex lifecycles.

Configuration-driven setup

Hardcoding model IDs and service IDs limits flexibility. Bind configuration to a strongly-typed options class:

public sealed class KernelOptions
{
    public string ModelId { get; set; } = "gpt-4o-mini";
    public string ServiceId { get; set; } = "default";
    public string ApiKey { get; set; } = string.Empty;
}

Register the options and use them when building the kernel:

builder.Services.Configure<KernelOptions>(builder.Configuration.GetSection("Kernel"));

builder.Services.AddSingleton<Kernel>(sp =>
{
    var options = sp.GetRequiredService<IOptions<KernelOptions>>().Value;
    var kernelBuilder = Kernel.CreateBuilder();
    kernelBuilder.AddOpenAIChatCompletion(
        modelId: options.ModelId,
        apiKey: options.ApiKey,
        serviceId: options.ServiceId);
    return kernelBuilder.Build();
});

Add appsettings.json:

{
  "Kernel": {
    "ModelId": "gpt-4o-mini",
    "ServiceId": "default",
    "ApiKey": ""
  }
}

The API key still comes from the environment at runtime — never commit secrets to configuration files. This pattern lets you swap models across environments without code changes.

Verifying the complete pipeline

Wire everything together in a single runnable example that exercises the kernel, a plugin, and a streaming response:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel;

var builder = Host.CreateApplicationBuilder(args);

builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Warning);

builder.Services.Configure<KernelOptions>(builder.Configuration.GetSection("Kernel"));
builder.Services.AddHttpClient<FactPlugin>();

builder.Services.AddSingleton<Kernel>(sp =>
{
    var options = sp.GetRequiredService<IOptions<KernelOptions>>().Value;
    var kernelBuilder = Kernel.CreateBuilder();
    kernelBuilder.AddOpenAIChatCompletion(
        modelId: options.ModelId,
        apiKey: options.ApiKey,
        serviceId: options.ServiceId);
    kernelBuilder.Plugins.AddFromType<FactPlugin>();
    return kernelBuilder.Build();
});

var app = builder.Build();

using var scope = app.Services.CreateScope();
var kernel = scope.ServiceProvider.GetRequiredService<Kernel>();

// 1. Simple prompt
var greeting = await kernel.InvokePromptAsync("Say 'DI works' in three words.");
Console.WriteLine($"Prompt result: {greeting}");

// 2. Plugin invocation
var fact = await kernel.InvokeAsync("FactPlugin", "get_random_fact");
Console.WriteLine($"Plugin result: {fact}");

// 3. Streaming with function calling
Console.Write("\nStreaming with tool use: ");
var streaming = kernel.InvokePromptStreamingAsync(
    "Get a random fact and summarize it in one sentence.",
    new KernelArguments { { "max_tokens", 100 } });

await foreach (var chunk in streaming)
{
    Console.Write(chunk);
}
Console.WriteLine();

Run the final version. You should see three distinct outputs: the prompt response, the plugin fact, and a streaming summary that demonstrates the model calling the plugin function autonomously.

Common pitfalls

Registering the kernel as transient when plugins hold state. If a plugin captures scoped dependencies (like a DbContext), the kernel must share the same scope. Use AddScoped<Kernel>() in ASP.NET Core or create a scope manually in console apps.

Forgetting to register HttpClient for plugins. The AddHttpClient<T>() extension handles lifetime and disposal. Manually new HttpClient() inside a plugin leaks sockets.

Mixing service IDs. kernel.GetRequiredService<IChatCompletionService>() without an ID returns the default service. If you registered multiple services without a default, this throws. Always use the overload that accepts a service ID when multiple services exist.

Disposing the host before streaming completes. In the streaming example, the await foreach keeps the scope alive. If you dispose the scope mid-stream, the kernel and its services are disposed, causing ObjectDisposedException.

When to use a gateway

If your application routes requests across multiple providers — OpenAI, Anthropic, local models — you can replace the direct AddOpenAIChatCompletion calls with a single OpenAI-compatible endpoint that handles fallback, load balancing, and usage metering. n4n.ai provides exactly that: one endpoint addressing 240+ models with automatic fallback when a provider is rate-limited or degraded, per-token usage metering, and it honors client routing directives while forwarding provider cache-control hints. The DI setup stays the same; you only change the base URL and API key.

Next steps

  • Add Microsoft.SemanticKernel.Plugins.Core for built-in plugins (time, file I/O, HTTP).
  • Implement IKernelFunctionFilter for cross-cutting concerns like logging, retries, or token counting.
  • Use KernelFunctionFromPrompt to register prompt templates as first-class DI services.
  • Explore AddKeyedSingleton / AddKeyedScoped in .NET 8+ for named kernel registrations without a factory.

The patterns here scale from a console utility to a multi-tenant API. Start simple, inject dependencies explicitly, and let the container manage lifetimes.

Tagssemantic-kerneldotnetdependency-injectiontutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel for .net enterprise apps posts →