Zero-friction multi-tenancy middleware for ASP.NET Core.
Built by Atypical Consulting.
- The Problem
- The Solution
- Features
- Tech Stack
- Packages
- Quick Start
- Available Resolvers
- Core Contracts
- Architecture
- Project Structure
- Roadmap
- Stats
- Contributing
- License
Every .NET SaaS application hits the multi-tenancy wall sooner or later.
Existing solutions like Finbuckle.MultiTenant are powerful but heavy — they're deeply coupled to Entity Framework, assume you want per-tenant databases, and require significant configuration before you get a tenant in scope.
You shouldn't need to fight your framework to know who's asking.
TenantKit resolves the current tenant in 3 lines:
// Program.cs
builder.Services.AddTenantKit(tk => tk
.UseHeaderResolver()
.UseQueryStringResolver()
.WithInMemoryStore(s => {
s.Add(new Tenant { Id = "acme", Name = "Acme Corp" });
s.Add(new Tenant { Id = "globex", Name = "Globex Inc." });
}));
app.UseTenantKit();Then inject ITenantContext anywhere:
app.MapGet("/hello", (ITenantContext ctx) =>
$"Hello from tenant: {ctx.CurrentTenant?.Name ?? "unknown"}");- Header-based tenant resolution (
X-Tenant-Id) - Query string tenant resolution (
?tenantId=) - Subdomain-based tenant resolution
- JWT/cookie claim-based tenant resolution
- Route value tenant resolution
- Composite resolver (try multiple strategies in order)
- In-memory tenant store
- Scoped per-request
ITenantContext - Fluent builder API for configuration
- Custom
ITenantStoresupport (bring your own data source) - Custom
ITenantResolversupport - Typed exceptions (
TenantNotFoundException,TenantResolutionException) - EF Core integration (planned)
- Redis-backed distributed tenant store (planned)
- Per-tenant rate limiting (planned)
- Per-tenant configuration overrides (planned)
| Layer | Technology |
|---|---|
| Runtime | .NET 10.0 |
| Framework | ASP.NET Core |
| Language | C# 13 |
| Testing | xUnit |
| CI | GitHub Actions |
| Package | Description | NuGet |
|---|---|---|
TenantKit.Core |
Contracts + InMemoryTenantStore | |
TenantKit.AspNetCore |
Middleware, resolvers, DI extensions |
dotnet add package TenantKit.AspNetCorebuilder.Services.AddTenantKit(tk => tk
.UseHeaderResolver() // X-Tenant-Id header
.UseQueryStringResolver() // ?tenantId=acme
.WithInMemoryStore(s => {
s.Add(new Tenant { Id = "acme", Name = "Acme Corp" });
}));
app.UseTenantKit();// In a controller or minimal API
public class OrdersController : ControllerBase
{
public OrdersController(ITenantContext tenantContext) { ... }
[HttpGet]
public IActionResult Get()
{
var tenant = _tenantContext.CurrentTenant;
// Filter data by tenant.Id
}
}| Resolver | Looks at | Example |
|---|---|---|
HeaderTenantResolver |
X-Tenant-Id request header |
X-Tenant-Id: acme |
QueryStringTenantResolver |
tenantId query param |
?tenantId=acme |
SubdomainTenantResolver |
First subdomain segment | acme.myapp.com |
ClaimTenantResolver |
JWT/cookie claim | tenant_id claim |
RouteValueTenantResolver |
Route parameter | /{tenantId}/orders |
CompositeTenantResolver |
Try resolvers in order | First non-null wins |
Configure multiple resolvers — they're tried in registration order:
builder.Services.AddTenantKit(tk => tk
.UseHeaderResolver()
.UseSubdomainResolver()
.UseClaimResolver(claimType: "tenant_id")
.WithInMemoryStore(...));public interface ITenant
{
string Id { get; }
string Name { get; }
}
public interface ITenantStore
{
Task<ITenant?> FindByIdAsync(string tenantId, CancellationToken ct = default);
Task<IReadOnlyList<ITenant>> GetAllAsync(CancellationToken ct = default);
}
public interface ITenantContext
{
ITenant? CurrentTenant { get; }
}Request
│
▼
TenantMiddleware
│ calls resolvers in order
├─► HeaderTenantResolver
├─► QueryStringTenantResolver
└─► ... (first non-null tenantId wins)
│
▼
ITenantStore.FindByIdAsync(tenantId)
│
▼
HttpTenantContext (scoped, per-request)
│
▼
Downstream handlers / controllers / services
TenantKit/
├── src/
│ ├── TenantKit.Core/
│ │ ├── ITenant.cs
│ │ ├── ITenantStore.cs
│ │ ├── ITenantContext.cs
│ │ ├── ITenantResolver.cs
│ │ ├── Tenant.cs
│ │ ├── InMemoryTenantStore.cs
│ │ ├── TenantNotFoundException.cs
│ │ └── TenantResolutionException.cs
│ └── TenantKit.AspNetCore/
│ ├── Resolvers/
│ │ ├── HeaderTenantResolver.cs
│ │ ├── QueryStringTenantResolver.cs
│ │ ├── SubdomainTenantResolver.cs
│ │ ├── ClaimTenantResolver.cs
│ │ ├── RouteValueTenantResolver.cs
│ │ └── CompositeTenantResolver.cs
│ ├── TenantMiddleware.cs
│ ├── HttpTenantContext.cs
│ ├── TenantKitBuilder.cs
│ ├── TenantKitOptions.cs
│ ├── ServiceCollectionExtensions.cs
│ └── ApplicationBuilderExtensions.cs
├── tests/
│ └── TenantKit.Tests/ # 24/24 tests passing ✅
└── samples/
└── TenantKit.Demo/ # Minimal API sample
- EF Core integration —
TenantKit.EntityFrameworkCore— per-tenant DbContext factory - Redis cache —
TenantKit.Redis— distributed tenant store backed by Redis - Per-tenant rate limiting —
TenantKit.RateLimiting— ASP.NET Core rate limiter integration - Per-tenant configuration — override
IConfigurationper tenant - NuGet publish — to nuget.org once API is stable
Contributions are welcome! Please open an issue first for large changes.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit using conventional commits (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
MIT © 2026 Atypical Consulting
Built with care by Atypical Consulting — opinionated, production-grade open source.