Kanject.Core.Tenancy
3.9.6
Prefix Reserved
dotnet add package Kanject.Core.Tenancy --version 3.9.6
NuGet\Install-Package Kanject.Core.Tenancy -Version 3.9.6
<PackageReference Include="Kanject.Core.Tenancy" Version="3.9.6" />
<PackageVersion Include="Kanject.Core.Tenancy" Version="3.9.6" />
<PackageReference Include="Kanject.Core.Tenancy" />
paket add Kanject.Core.Tenancy --version 3.9.6
#r "nuget: Kanject.Core.Tenancy, 3.9.6"
#:package Kanject.Core.Tenancy@3.9.6
#addin nuget:?package=Kanject.Core.Tenancy&version=3.9.6
#tool nuget:?package=Kanject.Core.Tenancy&version=3.9.6
Kanject Tenancy
The minimal contract every multi-tenant Kanject service shares: a single scoped ITenantContext that answers "which tenant is this request for?", plus the one canonical header name everything agrees on.
⚠️ The Problem
Multi-tenancy leaks tenant identity into every layer, and without a shared contract it does so inconsistently:
- The tenant ID is resolved a dozen different ways. One handler reads a JWT claim, another a route value, a third an
x-tenantheader, a fourthX-TenantId. Casing and header names drift, and the data layer can't trust any single source. - Tenant identity gets threaded by hand. Passing a
tenantIdstring down through service → repository → query is noisy, easy to forget, and trivially mis-wired (last week's tenant captured in a closure, the wrong overload called). - The header name is a magic string.
"x-tenant-id"ends up hard-coded in the gateway, the middleware, the integration tests, and the docs — each a separate place to typo, each drifting independently. - The persistence layer has no contract to bind to. A repository that wants to scope every query to the current tenant needs something stable to depend on — not an
HttpContextit shouldn't see, and not a constructorstringit can't get from DI.
🛠️ The Solution
Two tiny, dependency-free pieces:
| Piece | Purpose |
|---|---|
ITenantContext |
The one scoped service that exposes string TenantId — the single source of truth for the current tenant |
TenantConstants |
TenantHeaderName = "x-tenant-id" — the canonical header, hoisted off a magic string |
The key principle: resolve the tenant once, at the edge, into a scoped ITenantContext; everything downstream depends on the interface, never on where the ID came from. Swap header-based resolution for a JWT claim without touching a single repository.
Hoisting "x-tenant-id" onto TenantConstants follows the SDK's no-magic-strings rule — string literals that cross subsystem boundaries live on a *Constants type, never inline.
📖 Comprehensive Use Cases & Examples
1. Implement and register the context
Kanject.Core ships the contract; you own resolution. Implement ITenantContext for your edge (ASP.NET Core, Lambda, worker) and register it scoped so each request/invocation gets its own tenant.
using Kanject.Core.Tenancy;
using Kanject.Core.Tenancy.Constants;
public sealed class HttpTenantContext : ITenantContext
{
public HttpTenantContext(IHttpContextAccessor accessor)
{
// Resolve from the canonical header — no magic string.
TenantId = accessor.HttpContext?
.Request.Headers[TenantConstants.TenantHeaderName]
.ToString() ?? string.Empty;
}
public string TenantId { get; }
}
// Startup — scoped, so it never bleeds across requests.
services.AddHttpContextAccessor();
services.AddScoped<ITenantContext, HttpTenantContext>();
2. Resolve from a JWT claim instead (no downstream changes)
Because everything depends on the interface, the resolution strategy is a single swap:
public sealed class ClaimsTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
public string TenantId =>
accessor.HttpContext?.User.FindFirst("tenant_id")?.Value ?? string.Empty;
}
services.AddScoped<ITenantContext, ClaimsTenantContext>();
3. Consume it downstream
Inject ITenantContext wherever a tenant boundary matters — services, repositories, query builders — and let DI hand you the right scoped instance.
public sealed class OrderRepository(ITenantContext tenant, IDbContext db)
{
public Task<IReadOnlyList<Order>> ListAsync(CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.TenantId))
throw new InvalidOperationException("No tenant resolved for this request.");
// Scope every query to the current tenant — partition key, filter, etc.
return db.QueryByTenantAsync<Order>(tenant.TenantId, ct);
}
}
4. The header name, everywhere
Reference TenantConstants.TenantHeaderName from middleware, gateways, outbound clients, and tests so there is exactly one spelling of "x-tenant-id" in the system.
// Propagating the tenant to a downstream service call:
httpClient.DefaultRequestHeaders.Add(TenantConstants.TenantHeaderName, tenant.TenantId);
🧪 Testing
Because ITenantContext is a one-property interface, tests need no HTTP, no DI container, and no header plumbing — hand-roll a stub:
sealed file class FixedTenant(string id) : ITenantContext { public string TenantId => id; }
[Fact]
public async Task Repository_Scopes_To_Current_Tenant()
{
var repo = new OrderRepository(new FixedTenant("acme"), fakeDb);
var orders = await repo.ListAsync(CancellationToken.None);
Assert.All(orders, o => Assert.Equal("acme", o.TenantId));
}
🏗️ Architecture
Kanject.Core/Tenancy/
├── ITenantContext.cs # string TenantId { get; } — register scoped
└── Constants/
└── TenantConstants.cs # const TenantHeaderName = "x-tenant-id"
Design notes:
- Contract only — no implementation.
Kanject.Coredeliberately ships zero resolution logic. The edge (HTTP, Lambda, message envelope) is where tenant identity lives, and that's host-specific; binding the core SDK toHttpContextwould couple every consumer to ASP.NET Core. You provide the implementation; the SDK provides the seam. - Scoped lifetime is the contract's expectation. A singleton
ITenantContextwould bleed one request's tenant into another — the same class of cross-request state-sharing bug that affects any per-unit collaborator registered as a singleton. Register it scoped. TenantIdis non-null by interface. Implementations should returnstring.Empty(notnull) when no tenant is resolvable, and tenant-scoped operations should reject empty up front rather than silently querying across tenants.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Kanject.Core.Tenancy:
| Package | Downloads |
|---|---|
|
Kanject.Core.Api
Kanject core api |
|
|
Kanject.Core.ApiV2
Kanject core api V2 |
GitHub repositories
This package is not used by any popular GitHub repositories.