n4nAI

Semantic Kernel enterprise tutorial: multi-tenant setup

A practical guide to configuring Semantic Kernel for multi-tenant .NET enterprise applications, covering kernel isolation, tenant resolution, plugin scoping, and observability patterns.

n4n Team4 min read835 words

Audio narration

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

Multi-tenant Semantic Kernel deployments fail when teams treat the kernel as a singleton. The framework’s dependency injection model encourages shared state, but enterprise workloads demand strict tenant boundaries for data, plugins, and model routing. This guide walks through a semantic kernel enterprise multi-tenant configuration that keeps tenants isolated without duplicating infrastructure.

Choose your isolation model

Before writing code, decide what “tenant” means for your workload. Three patterns cover most enterprise scenarios:

Shared kernel, scoped services — One Kernel instance per request, but plugins, memory stores, and HTTP clients resolve per-tenant via IServiceScopeFactory. Lowest memory overhead, highest complexity in plugin registration.

Kernel per tenant — Each tenant gets a dedicated Kernel instance cached in a ConcurrentDictionary<string, Kernel>. Simpler mental model, but you must manage kernel lifecycle and dispose plugins when tenants churn.

Process isolation — Separate worker processes or containers per tenant. Maximum isolation, highest operational cost. Rarely justified unless regulatory requirements demand it.

For most .NET enterprise apps, scoped services with a shared kernel builder hits the sweet spot. You configure the kernel once at startup, then push tenant context through the DI container at request time.

Configure the kernel builder at startup

Register a KernelBuilder singleton that holds the template — model endpoints, default plugins, logging — but defers tenant-specific services to request scope.

// Program.cs or Startup.cs
builder.Services.AddSingleton<KernelBuilder>(sp =>
{
    var kernelBuilder = Kernel.CreateBuilder();

    // Shared model configuration — can be overridden per-tenant later
    kernelBuilder.AddOpenAIChatCompletion(
        modelId: "gpt-4o-mini",
        apiKey: builder.Configuration["OpenAI:ApiKey"]);

    // Shared plugins available to all tenants
    kernelBuilder.Plugins.AddFromType<TimePlugin>();
    kernelBuilder.Plugins.AddFromType<CalculatorPlugin>();

    // Shared memory store *factory* — actual store resolved per-tenant
    kernelBuilder.Services.AddSingleton<IVectorStoreFactory, TenantVectorStoreFactory>();

    return kernelBuilder;
});

Pitfall: Registering Kernel as a singleton. The kernel holds plugin collections and function caches that must not leak across tenants. Always build the kernel inside a scope.

Resolve tenant context early

Extract the tenant identifier before the kernel enters the picture. A middleware pipeline keeps this logic out of your handlers.

public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ITenantResolver _resolver;

    public TenantResolutionMiddleware(RequestDelegate next, ITenantResolver resolver)
    {
        _next = next;
        _resolver = resolver;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var tenantId = _resolver.Resolve(context);
        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Tenant context required");
            return;
        }

        // Push into scoped DI for the rest of the request
        using var scope = context.RequestServices.CreateScope();
        var tenantContext = scope.ServiceProvider.GetRequiredService<ITenantContext>();
        tenantContext.TenantId = tenantId;

        // Continue with scoped services available
        await _next(context);
    }
}

ITenantResolver implementations vary: JWT claim, subdomain, header, or database lookup. Keep the interface simple so you can swap strategies without touching kernel code.

public interface ITenantResolver
{
    string? Resolve(HttpContext context);
}

public class JwtTenantResolver : ITenantResolver
{
    public string? Resolve(HttpContext context)
    {
        return context.User.FindFirst("tenant_id")?.Value
            ?? context.User.FindFirst("tid")?.Value; // Azure AD
    }
}

Build the kernel per request

With tenant context in the scoped container, build the kernel inside your handler or a dedicated factory. This is where the semantic kernel enterprise multi-tenant configuration pays off — each request gets a clean kernel with tenant-scoped plugins and memory.

public class KernelFactory
{
    private readonly KernelBuilder _kernelBuilder;
    private readonly IServiceScopeFactory _scopeFactory;

    public KernelFactory(KernelBuilder kernelBuilder, IServiceScopeFactory scopeFactory)
    {
        _kernelBuilder = kernelBuilder;
        _scopeFactory = scopeFactory;
    }

    public Kernel CreateKernel(string tenantId)
    {
        var scope = _scopeFactory.CreateScope();
        
        // Register tenant-specific services in this scope
        scope.ServiceProvider.GetRequiredService<ITenantContext>().TenantId = tenantId;
        
        // Tenant-scoped vector store
        var vectorStoreFactory = scope.ServiceProvider.GetRequiredService<IVectorStoreFactory>();
        var tenantStore = vectorStoreFactory.CreateForTenant(tenantId);
        scope.ServiceProvider.GetRequiredService<IServiceCollection>()
            .AddSingleton(tenantStore);

        // Tenant-scoped plugins (e.g., CRM connectors with tenant API keys)
        RegisterTenantPlugins(scope.ServiceProvider, tenantId);

        // Build kernel with scoped services
        var kernel = _kernelBuilder.Build(scope.ServiceProvider);
        
        // Attach scope for disposal
        kernel.Data["__scope"] = scope;
        
        return kernel;
    }

    private void RegisterTenantPlugins(IServiceProvider sp, string tenantId)
    {
        var config = sp.GetRequiredService<IConfiguration>();
        var crmKey = config[$"Tenants:{tenantId}:CrmApiKey"];
        
        if (!string.IsNullOrEmpty(crmKey))
        {
            var plugin = new CrmPlugin(crmKey);
            sp.GetRequiredService<KernelPluginCollection>().AddFromObject(plugin);
        }
    }
}

Tradeoff: Creating a scope per kernel build adds allocation overhead. In high-throughput scenarios, pool scopes or use a Kernel cache keyed by tenant ID with a TTL. Invalidate the cache when tenant configuration changes.

Isolate vector stores per tenant

Shared vector stores are a data leakage risk. Implement IVectorStoreFactory to return tenant-scoped collections — either separate indexes, namespaces, or database schemas.

public interface IVectorStoreFactory
{
    IVectorStore CreateForTenant(string tenantId);
}

public class QdrantVectorStoreFactory : IVectorStoreFactory
{
    private readonly QdrantClient _client;
    private readonly IConfiguration _config;

    public QdrantVectorStoreFactory(QdrantClient client, IConfiguration config)
    {
        _client = client;
        _config = config;
    }

    public IVectorStore CreateForTenant(string tenantId)
    {
        var collectionName = $"tenant_{tenantId}_embeddings";
        
        // Ensure collection exists — idempotent
        _client.CreateCollectionAsync(collectionName, new VectorParams
        {
            Size = 1536,
            Distance = Distance.Cosine
        }).Wait();

        return new QdrantVectorStore(_client, collectionName);
    }
}

If you use a single collection with payload filtering, add a strict filter to every search:

public class TenantFilteredMemory : IMemoryStore
{
    private readonly IMemoryStore _inner;
    private readonly string _tenantId;

    public TenantFilteredMemory(IMemoryStore inner, string tenantId)
    {
        _inner = inner;
        _tenantId = tenantId;
    }

    public async Task<IEnumerable<MemoryRecord>> GetNearestMatchesAsync(
        string collection, 
        Vector vector, 
        int limit, 
        double minRelevanceScore, 
        MemoryFilter? filter, 
        CancellationToken cancellationToken = default)
    {
        var tenantFilter = MemoryFilter.ByTag("tenant_id", _tenantId);
        var combined = filter is null ? tenantFilter : filter.And(tenantFilter);
        
        return await _inner.GetNearestMatchesAsync(
            collection, vector, limit, minRelevanceScore, combined, cancellationToken);
    }

    // ... delegate other methods with same filter injection
}

Pitfall: Forgetting to filter on write. Every SaveInformationAsync call must include the tenant tag, or you’ll pollute other tenants’ results.

Scope plugins to tenant capabilities

Not every tenant gets every plugin. Enterprise contracts often gate features — premium tenants get CRM access, basic tenants don’t. Model this with a capability registry.

public interface ITenantCapabilityRegistry
{
    IReadOnlySet<string> GetCapabilities(string tenantId);
}

public class ConfigurationCapabilityRegistry : ITenantCapabilityRegistry
{
    private readonly IConfiguration _config;
    private readonly ConcurrentDictionary<string, HashSet<string>> _cache = new();

    public IReadOnlySet<string> GetCapabilities(string tenantId)
    {
        return _cache.GetOrAdd(tenantId, id =>
        {
            var section = _config.GetSection($"Tenants:{id}:Capabilities");
            return new HashSet<string>(section.Get<string[]>() ?? Array.Empty<string>(), 
                StringComparer.OrdinalIgnoreCase);
        });
    }
}

Then register plugins conditionally in KernelFactory:

private void RegisterTenantPlugins(IServiceProvider sp, string tenantId)
{
    var capabilities = sp.GetRequiredService<ITenantCapabilityRegistry>()
        .GetCapabilities(tenantId);
    var pluginCollection = sp.GetRequiredService<KernelPluginCollection>();

    if (capabilities.Contains("crm"))
    {
        var crmKey = sp.GetRequiredService<IConfiguration>()[$"Tenants:{tenantId}:CrmApiKey"];
        pluginCollection.AddFromObject(new CrmPlugin(crmKey));
    }

    if (capabilities.Contains("billing"))
    {
        pluginCollection.AddFromObject(new BillingPlugin(tenantId));
    }

    // Custom function plugins per tenant
    if (capabilities.Contains("custom_functions"))
    {
        var customAssembly = LoadTenantAssembly(tenantId);
        pluginCollection.AddFromType(customAssembly);
    }
}

Handle model routing per tenant

Different tenants may need different models — cost optimization, compliance, or feature access. Configure this at the kernel builder level using a model selector.

public class TenantModelSelector : IChatCompletionServiceSelector
{
    private readonly ITenantContext _tenantContext;
    private readonly IConfiguration _config;
    private readonly IServiceProvider _services;

    public TenantModelSelector(ITenantContext tenantContext, IConfiguration config, IServiceProvider services)
    {
        _tenantContext = tenantContext;
        _config = config;
        _services = services;
    }

    public IChatCompletionService SelectService(Kernel kernel, ChatHistory history, PromptExecutionSettings? settings)
    {
        var tenantId = _tenantContext.TenantId;
        var modelOverride = _config[$"Tenants:{tenantId}:ChatModel"];
        
        if (!string.IsNullOrEmpty(modelOverride))
        {
            // Resolve named service registered at startup
            return _services.GetKeyedService<IChatCompletionService>(modelOverride);
        }

        // Fallback to default
        return _services.GetRequiredService<IChatCompletionService>();
    }
}

Register multiple model services at startup with keys:

builder.Services.AddKeyedSingleton<IChatCompletionService>("gpt-4o", (sp, key) =>
    new OpenAIChatCompletionService("gpt-4o", sp.GetRequiredService<IConfiguration>()["OpenAI:ApiKey"]));

builder.Services.AddKeyedSingleton<IChatCompletionService>("gpt-4o-mini", (sp, key) =>
    new OpenAIChatCompletionService("gpt-4o-mini", sp.GetRequiredService<IConfiguration>()["OpenAI:ApiKey"]));

builder.Services.AddKeyedSingleton<IChatCompletionService>("azure-gpt4", (sp, key) =>
    new AzureChatCompletionService(
        deploymentName: "gpt-4",
        endpoint: sp.GetRequiredService<IConfiguration>()["AzureOpenAI:Endpoint"],
        apiKey: sp.GetRequiredService<IConfiguration>()["AzureOpenAI:ApiKey"]));

// Register the selector
builder.Services.AddSingleton<IChatCompletionServiceSelector, TenantModelSelector>();

Note: If you’re routing across multiple providers (OpenAI, Azure, Anthropic) and want automatic fallback when one degrades, a gateway like n4n.ai handles that at the HTTP layer without kernel changes — you just point the kernel at one endpoint.

Add observability with tenant context

Structured logging and metrics need tenant IDs on every event. Enrich the Kernel pipeline with a filter.

public class TenantTelemetryFilter : IFunctionInvocationFilter
{
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<TenantTelemetryFilter> _logger;
    private readonly Meter _meter;

    public TenantTelemetryFilter(ITenantContext tenantContext, ILogger<TenantTelemetryFilter> logger, IMeterFactory meterFactory)
    {
        _tenantContext = tenantContext;
        _logger = logger;
        _meter = meterFactory.Create("SemanticKernel.Tenant");
    }

    public async Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<Task> next)
    {
        var tenantId = _tenantContext.TenantId ?? "unknown";
        var functionName = context.Function.Name;
        
        using var activity = _meter.CreateActivity($"{functionName}.invoke");
        activity?.SetTag("tenant.id", tenantId);
        activity?.SetTag("function.name", functionName);

        var stopwatch = Stopwatch.StartNew();
        try
        {
            _logger.LogInformation("Tenant {TenantId} invoking {Function}", tenantId, functionName);
            await next();
            stopwatch.Stop();
            
            _meter.CreateCounter<long>("sk.function.invocations")
                .Add(1, new KeyValuePair<string, object?>("tenant.id", tenantId),
                     new KeyValuePair<string, object?>("function", functionName),
                     new KeyValuePair<string, object?>("status", "success"));
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(ex, "Tenant {TenantId} function {Function} failed", tenantId, functionName);
            
            _meter.CreateCounter<long>("sk.function.invocations")
                .Add(1, new KeyValuePair<string, object?>("tenant.id", tenantId),
                     new KeyValuePair<string, object?>("function", functionName),
                     new KeyValuePair<string, object?>("status", "error"));
            throw;
        }
    }
}

Register it in the kernel builder:

kernelBuilder.Services.AddSingleton<IFunctionInvocationFilter, TenantTelemetryFilter>();

Dispose kernels and scopes cleanly

Kernels built per-request must release their scopes. A middleware or filter handles this.

public class KernelScopeDisposalMiddleware
{
    private readonly RequestDelegate _next;

    public KernelScopeDisposalMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        finally
        {
            // Kernel stored in HttpContext.Items by your handler
            if (context.Items.TryGetValue("Kernel", out var obj) && obj is Kernel kernel)
            {
                if (kernel.Data.TryGetValue("__scope", out var scopeObj) && scopeObj is IServiceScope scope)
                {
                    scope.Dispose();
                }
            }
        }
    }
}

Alternatively, register the kernel as Scoped in DI and let the container dispose it — but only if you build the kernel inside the request scope, not from a factory that creates its own scope.

Common pitfalls

Plugin state leakage — Plugins instantiated as singletons share state across tenants. Always register plugins as Transient or Scoped, or ensure they’re stateless.

// Wrong: singleton plugin with tenant state
builder.Services.AddSingleton<CrmPlugin>();

// Correct: transient, resolved per kernel build
builder.Services.AddTransient<CrmPlugin>();

Configuration reload — Tenant capabilities change. If you cache Kernel instances, you need a cache invalidation strategy. IOptionsMonitor<TOptions> with a change token works, or publish an event when tenant config updates and evict the kernel from the cache.

Thread safety in KernelPluginCollection — The collection is not thread-safe for writes. Build the kernel once per scope, then treat it as read-only. Never modify kernel.Plugins after the kernel escapes the factory.

Memory store connection pooling — Each tenant getting a dedicated vector store connection can exhaust pool limits. Configure pool sizes per-tenant or use a shared client with tenant-scoped collections (as shown in the Qdrant factory).

Testing the multi-tenant pipeline

Integration tests should spin up the full DI container and verify isolation.

[Fact]
public async Task Kernel_IsolatesTenantPlugins()
{
    // Arrange
    var host = await new HostBuilder()
        .ConfigureServices((ctx, services) =>
        {
            services.AddSingleton<KernelBuilder>(sp => CreateTestKernelBuilder());
            services.AddScoped<ITenantContext, TenantContext>();
            services.AddTransient<KernelFactory>();
            services.AddTransient<CrmPlugin>();
            services.Configure<Dictionary<string, string>>("Tenants:tenant-a", opts =>
                opts["Capabilities"] = "crm");
            services.Configure<Dictionary<string, string>>("Tenants:tenant-b", opts =>
                opts["Capabilities"] = "");
        })
        .Build();

    var factory = host.Services.GetRequiredService<KernelFactory>();

    // Act
    var kernelA = factory.CreateKernel("tenant-a");
    var kernelB = factory.CreateKernel("tenant-b");

    // Assert
    Assert.Contains(kernelA.Plugins, p => p.Name == "CrmPlugin");
    Assert.DoesNotContain(kernelB.Plugins, p => p.Name == "CrmPlugin");
}

Summary

A production semantic kernel enterprise multi-tenant configuration centers on three principles: build kernels per request from a shared template, push tenant context through scoped DI, and isolate every stateful dependency — plugins, memory stores, model clients — behind tenant-aware factories. The middleware pipeline resolves the tenant early; the factory builds a clean kernel with scoped services; telemetry filters enrich every call with tenant IDs. This pattern scales to thousands of tenants without kernel singleton contamination or data leakage.

Tagssemantic-kernelenterprisemulti-tenantconfiguration

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 →