MultiTenant 1.2607.227.7

dotnet add package MultiTenant --version 1.2607.227.7
                    
NuGet\Install-Package MultiTenant -Version 1.2607.227.7
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MultiTenant" Version="1.2607.227.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MultiTenant" Version="1.2607.227.7" />
                    
Directory.Packages.props
<PackageReference Include="MultiTenant" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MultiTenant --version 1.2607.227.7
                    
#r "nuget: MultiTenant, 1.2607.227.7"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MultiTenant@1.2607.227.7
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MultiTenant&version=1.2607.227.7
                    
Install as a Cake Addin
#tool nuget:?package=MultiTenant&version=1.2607.227.7
                    
Install as a Cake Tool

MultiTenant

Multi-tenancy and distributed coordination library for .NET containerised backends.
PostgreSQL-native — no Redis, no Zookeeper, no external broker.

NuGet License: MIT .NET


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 SQLFactory NuGet 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_idX-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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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