MultiTenant 1.2607.227.7
dotnet add package MultiTenant --version 1.2607.227.7
NuGet\Install-Package MultiTenant -Version 1.2607.227.7
<PackageReference Include="MultiTenant" Version="1.2607.227.7" />
<PackageVersion Include="MultiTenant" Version="1.2607.227.7" />
<PackageReference Include="MultiTenant" />
paket add MultiTenant --version 1.2607.227.7
#r "nuget: MultiTenant, 1.2607.227.7"
#:package MultiTenant@1.2607.227.7
#addin nuget:?package=MultiTenant&version=1.2607.227.7
#tool nuget:?package=MultiTenant&version=1.2607.227.7
MultiTenant
Multi-tenancy and distributed coordination library for .NET containerised backends.
PostgreSQL-native — no Redis, no Zookeeper, no external broker.
What it is
MultiTenant gives every containerised .NET service the coordination primitives it needs to run safely as multiple replicas across multiple tenants — using only the PostgreSQL database you already have.
| Primitive | What it does |
|---|---|
| Tenant context | Resolves tenant_id from JWT claim, X-Tenant-Id header, or route; injects it into every request scope |
| Node heartbeat | Each replica registers itself and publishes a heartbeat every 30 s; stale nodes are automatically excluded |
| Leader election | One active leader per app via a PostgreSQL fencing-token lease; safe for scheduled jobs and singleton workers |
| Task outbox | Distributed task queue backed by FOR UPDATE SKIP LOCKED; reliable background jobs without a message broker |
| Event outbox | At-least-once cross-replica event delivery; acknowledgement tracked per consumer |
| Inbox | Exactly-once idempotency guard; deduplicates incoming events by message ID |
| Distributed cache | IDistributedCache implementation on the coordination database; no Redis required |
| Rate limiter | Token-bucket rate limiter with shared state across all replicas |
| Circuit breaker | Closed / Open / Half-Open state machine persisted in PostgreSQL |
| CRDT store | GCounter, LwwRegister, and OR-Set conflict-free replicated data types |
| Saga orchestrator | Long-running processes with compensating transactions |
| Quartz clustering | Drop-in Quartz.NET cluster configuration backed by the same coordination database |
Requirements
- .NET 8, 9, or 10
- PostgreSQL 13+ (recommended: 15+)
- The
SQLFactoryNuGet package (pulled in automatically as a dependency)
Installation
dotnet add package MultiTenant
Quick start — minimum
// Program.cs
builder.Services
.AddMultiTenant() // reads DATABASE_URL env var automatically
.WithAppName("my-service");
app.UseMultiTenant(); // resolves tenant_id per request
This registers: node heartbeat, leader election, task outbox, event outbox, inbox, tenant middleware, health checks, and schema bootstrap — all with sensible defaults.
Quick start — explicit connection + selected features
builder.Services
.AddMultiTenant()
.WithConnectionString("Host=db;Database=myapp;Username=app;Password=secret")
.WithAppName("my-service")
.WithSchema("_cluster_sync") // default; change if you need a different schema
.WithHeartbeat(interval: TimeSpan.FromSeconds(15))
.WithLeaderElection(leaseTtl: TimeSpan.FromSeconds(30))
.WithTaskOutbox(pollingInterval: TimeSpan.FromSeconds(2), batchSize: 20)
.WithEventOutbox()
.WithTenantMiddleware(claimType: "tid", headerName: "X-Tenant")
.WithRlsIsolation() // sets app.tenant_id on every opened connection
.WithDistributedCache()
.WithRateLimiter(maxTokens: 200, windowSeconds: 60)
.WithCircuitBreaker(failureThreshold: 3, openDuration: TimeSpan.FromSeconds(60))
.WithInbox()
.AddTaskHandler<SendEmailHandler>("send_email")
.AddTaskHandler<GenerateReportHandler>("generate_report")
.AddEventHandler<UserCreatedHandler>("user.created")
.AddLeaderAction("nightly-cleanup", async (fencingToken, ct) =>
{
// runs on the current leader only, on every renewal cycle
});
app.UseMultiTenant();
Tenant isolation
Two isolation modes are available:
Row Level Security (default)
.WithRlsIsolation()
The library sets SET LOCAL app.tenant_id = '<id>' on every PostgreSQL connection while a tenant scope is active. Your RLS policies reference current_setting('app.tenant_id'). No manual WHERE clause required in application queries.
Application filter
.WithApplicationFilterIsolation()
The library populates ITenantContext.CurrentTenantId only. Every query must include an explicit WHERE tenant_id = @tenantId. Use this when RLS policies cannot be applied (e.g. shared tables, migration tooling).
Reading the current tenant
public class MyService(ITenantContext tenant)
{
public Task DoWork()
{
var id = tenant.CurrentTenantId; // null when no tenant scope is active
// ...
}
}
Tenant resolution order: JWT claim tenant_id → X-Tenant-Id header → route value tenantId.
Task outbox
Enqueue background tasks that survive replica restarts:
public class OrderService(ITaskOutbox outbox)
{
public async Task PlaceOrder(Order order, CancellationToken ct)
{
await outbox.EnqueueAsync("send_confirmation_email", new
{
order.Id,
order.CustomerEmail
}, ct);
}
}
Implement the handler:
public class SendConfirmationEmailHandler : ITaskHandler
{
public async Task HandleAsync(string payload, CancellationToken ct)
{
var data = JsonSerializer.Deserialize<EmailPayload>(payload);
// send email
}
}
Register during startup:
.AddTaskHandler<SendConfirmationEmailHandler>("send_confirmation_email")
Tasks are claimed using FOR UPDATE SKIP LOCKED — only one replica processes each task. Failed tasks are retried up to TaskMaxAttempts times with TaskRetryDelay between attempts.
Event outbox
Publish events with at-least-once delivery across replicas:
public class UserService(IEventOutbox events)
{
public async Task CreateUser(User user, CancellationToken ct)
{
// ... save user ...
await events.PublishAsync("user.created", new { user.Id, user.Email }, ct);
}
}
Consume events:
public class UserCreatedHandler : IEventHandler
{
public async Task HandleAsync(string payload, CancellationToken ct)
{
var data = JsonSerializer.Deserialize<UserCreatedEvent>(payload);
// handle event
}
}
.AddEventHandler<UserCreatedHandler>("user.created")
Leader election
Elect a single leader across all replicas for singleton work:
public class ReportScheduler(ILeaderElection leader)
{
public async Task RunAsync(CancellationToken ct)
{
if (!await leader.IsLeaderAsync(ct))
return; // not the leader; skip
// safe to run; only one replica reaches this point
await GenerateDailyReportAsync(ct);
}
}
Or register a named leader action that fires on every lease renewal:
.AddLeaderAction("daily-report", async (fencingToken, ct) =>
{
await GenerateDailyReportAsync(ct);
})
The fencing token is a monotonically increasing long. Pass it to downstream operations to detect stale leaders.
Inbox — exactly-once idempotency
Deduplicate incoming messages by a stable message ID:
public class WebhookController(IInboxProcessor inbox) : ControllerBase
{
[HttpPost("webhook")]
public async Task<IActionResult> Receive([FromBody] WebhookPayload payload)
{
var processed = await inbox.ProcessOnceAsync(payload.MessageId, async ct =>
{
await HandleWebhookAsync(payload, ct);
}, HttpContext.RequestAborted);
return processed ? Ok() : Conflict("Already processed");
}
}
Distributed cache
Drop-in IDistributedCache backed by PostgreSQL — no Redis required:
.WithDistributedCache(cleanupInterval: TimeSpan.FromMinutes(10))
Use via the standard IDistributedCache interface:
public class ProductService(IDistributedCache cache)
{
public async Task<Product?> GetAsync(Guid id, CancellationToken ct)
{
var key = $"product:{id}";
var cached = await cache.GetStringAsync(key, ct);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _db.FindAsync(id, ct);
if (product is not null)
await cache.SetStringAsync(key, JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct);
return product;
}
}
Rate limiter
Token-bucket rate limiter with state shared across all replicas:
.WithRateLimiter(maxTokens: 100, windowSeconds: 60)
public class ApiController(IDistributedRateLimiter limiter) : ControllerBase
{
[HttpPost("action")]
public async Task<IActionResult> Action()
{
var allowed = await limiter.TryAcquireAsync(
key: $"user:{User.Identity!.Name}",
maxTokens: 10,
refillPerSecond: 1,
HttpContext.RequestAborted);
if (!allowed) return StatusCode(429);
// proceed
}
}
Circuit breaker
Cross-replica circuit breaker with PostgreSQL-persisted state:
.WithCircuitBreaker(failureThreshold: 5, openDuration: TimeSpan.FromSeconds(30))
public class PaymentService(IDistributedCircuitBreaker breaker)
{
public async Task<Result> ChargeAsync(PaymentRequest req, CancellationToken ct)
{
var state = await breaker.GetStateAsync("payment-gateway", ct);
if (state == CircuitState.Open)
return Result.Unavailable("Payment gateway circuit open");
try
{
var result = await _gateway.ChargeAsync(req, ct);
await breaker.RecordSuccessAsync("payment-gateway", ct);
return result;
}
catch (Exception ex)
{
await breaker.RecordFailureAsync("payment-gateway", ct);
throw;
}
}
}
CRDT store (opt-in)
Conflict-free replicated data types for counters and sets:
.WithCrdt()
public class AnalyticsService(ICrdtStore crdt)
{
// GCounter — increment-only, safe across replicas
public Task IncrementPageViewAsync(string page, CancellationToken ct)
=> crdt.IncrementAsync($"views:{page}", nodeId: Environment.MachineName, ct);
public Task<long> GetPageViewsAsync(string page, CancellationToken ct)
=> crdt.GetCounterValueAsync($"views:{page}", ct);
// LwwRegister — last-write-wins by timestamp
public Task SetStatusAsync(Guid userId, string status, CancellationToken ct)
=> crdt.SetRegisterAsync($"status:{userId}", status, DateTimeOffset.UtcNow, ct);
}
Saga orchestrator (opt-in)
Long-running workflows with compensating transactions:
.WithSaga<OrderSagaState, OrderSagaDefinition>()
public class OrderSagaDefinition : ISagaDefinition<OrderSagaState>
{
public IEnumerable<SagaStep<OrderSagaState>> Steps => new[]
{
new SagaStep<OrderSagaState>(
execute: (state, ct) => ReserveInventoryAsync(state, ct),
compensate: (state, ct) => ReleaseInventoryAsync(state, ct)),
new SagaStep<OrderSagaState>(
execute: (state, ct) => ChargePaymentAsync(state, ct),
compensate: (state, ct) => RefundPaymentAsync(state, ct)),
new SagaStep<OrderSagaState>(
execute: (state, ct) => DispatchShipmentAsync(state, ct),
compensate: null),
};
}
Quartz.NET clustering
Register Quartz with clustered scheduling backed by the same coordination database:
.WithQuartzClustering()
builder.Services.AddQuartz(q =>
{
q.AddJob<NightlyReportJob>(j => j.WithIdentity("nightly-report"));
q.AddTrigger(t => t
.ForJob("nightly-report")
.WithCronSchedule("0 0 2 * * ?")); // 02:00 daily
});
builder.Services.AddQuartzHostedService();
Only one node fires each trigger even when multiple replicas are running.
Health checks
Five health checks are registered automatically under the multitenant tag:
| Name | Reports | Condition |
|---|---|---|
multitenant-db |
Unhealthy |
Coordination database unreachable |
multitenant-heartbeat |
Degraded |
This node's last heartbeat is stale |
multitenant-leader |
Degraded |
Leader election is enabled but no leader has been elected |
multitenant-taskbacklog |
Degraded / Unhealthy |
Pending task count exceeds threshold |
multitenant-eventlag |
Degraded / Unhealthy |
Oldest unconsumed event exceeds age threshold |
Query all MultiTenant health checks via the standard ASP.NET Core health endpoint:
GET /health
GET /health?tags=multitenant
Configuration reference
All options can be set via the fluent builder, appsettings.json (section "MultiTenant"), or environment variables.
| Option | Default | Description |
|---|---|---|
AppName |
assembly name | Discriminator in coordination tables |
CoordinationConnectionString |
DATABASE_URL env var |
PostgreSQL connection string |
CoordinationSchema |
_cluster_sync |
Schema for coordination tables |
ProviderInvariantName |
"Npgsql" |
ADO.NET provider (e.g. "Npgsql", "Microsoft.Data.SqlClient", "MySqlConnector", "System.Data.SQLite") — SQLFactory does not auto-detect the provider from a bare connection string, so override this explicitly for non-PostgreSQL coordination stores |
HeartbeatInterval |
30 s | How often this node publishes a heartbeat |
NodeStaleThreshold |
2 min | Age at which a node is considered dead |
LeaderLeaseTtl |
60 s | Leader lease expiry |
LeaderLeaseRenewalInterval |
20 s | How often the leader renews its lease |
TaskPollingInterval |
5 s | Task outbox poll frequency |
TaskBatchSize |
10 | Tasks claimed per poll cycle |
TaskMaxAttempts |
3 | Max delivery attempts before failed |
TaskRetryDelay |
30 s | Delay before a failed task is retried |
EventPollingInterval |
5 s | Event outbox poll frequency |
EventBatchSize |
100 | Events processed per poll cycle |
TenantIdClaimType |
tenant_id |
JWT claim for tenant resolution |
TenantIdHeaderName |
X-Tenant-Id |
HTTP header for tenant resolution |
CacheCleanupInterval |
5 min | Distributed cache eviction frequency |
CircuitBreakerFailureThreshold |
5 | Failures before circuit opens |
CircuitBreakerOpenDuration |
30 s | Time circuit stays open |
RateLimitMaxTokens |
100 | Default bucket capacity |
RateLimitWindowSeconds |
60 | Token refill window |
HealthCheckTaskBacklogThreshold |
1000 | Tasks that trigger Unhealthy |
HealthCheckEventLagThreshold |
5 min | Event age that triggers Unhealthy |
appsettings.json binding example:
{
"MultiTenant": {
"AppName": "my-service",
"CoordinationSchema": "_cluster_sync",
"HeartbeatInterval": "00:00:15",
"LeaderLeaseTtl": "00:01:00",
"TaskPollingInterval": "00:00:02"
}
}
Coordination schema
All coordination state lives in the _cluster_sync schema (configurable). Tables are created automatically on first startup — no migration tool or manual DDL required.
| Table | Purpose |
|---|---|
nodes |
Live node registry; one row per replica, updated by heartbeat |
leader_leases |
Leader election lease with fencing token |
task_outbox |
Pending, processing, and completed background tasks |
event_outbox |
Published events and per-consumer acknowledgements |
inbox |
Processed message IDs for exactly-once deduplication |
distributed_cache |
Key-value cache entries with TTL |
rate_limit_buckets |
Token-bucket state per key |
circuit_breakers |
Circuit state per named breaker |
crdt_gcounters |
GCounter node-value pairs |
crdt_registers |
LwwRegister timestamped values |
crdt_sets |
OR-Set elements with tombstones |
saga_instances |
Saga state and step progress |
Connection factory (advanced)
If you manage your own connection pool:
.WithConnectionFactory(() => new NpgsqlConnection(connectionString))
This bypasses the SQLFactory auto-detection and uses your factory directly. Useful when connections are obtained from an existing pool or a custom resolver.
Multi-provider support
MultiTenant works with any ADO.NET-compatible database through SQLFactory. Dialects supported out of the box:
| Database | Provider |
|---|---|
| PostgreSQL | Npgsql |
| SQL Server | Microsoft.Data.SqlClient |
| MySQL / MariaDB | MySqlConnector |
| Oracle | Oracle.ManagedDataAccess.Client |
| SQLite | System.Data.SQLite (local state only; use PostgreSQL for coordination in distributed deployments) |
For SQLite-backed apps, use PostgreSQL for all coordination (heartbeat, leader, outbox) and SQLite for local business data.
License
MIT — see LICENSE.
| 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
- Microsoft.AspNetCore.Http.Abstractions (>= 2.3.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Quartz (>= 3.13.1)
- Quartz.Extensions.Hosting (>= 3.13.1)
- Quartz.Serialization.Json (>= 3.13.1)
- SQLFactory (>= 28.2607.208.216)
-
net8.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.3.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Quartz (>= 3.13.1)
- Quartz.Extensions.Hosting (>= 3.13.1)
- Quartz.Serialization.Json (>= 3.13.1)
- SQLFactory (>= 28.2607.208.216)
- System.Diagnostics.DiagnosticSource (>= 10.0.0)
- System.Text.Json (>= 10.0.0)
-
net9.0
- Microsoft.AspNetCore.Http.Abstractions (>= 2.3.0)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Quartz (>= 3.13.1)
- Quartz.Extensions.Hosting (>= 3.13.1)
- Quartz.Serialization.Json (>= 3.13.1)
- SQLFactory (>= 28.2607.208.216)
- System.Diagnostics.DiagnosticSource (>= 10.0.0)
- System.Text.Json (>= 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.2607.227.7 | 96 | 8/15/2026 |
| 1.2607.212.1 | 139 | 7/31/2026 |
| 1.0.0 | 103 | 7/31/2026 |