Skip to main content

Tenant Data Sharding

Route each tenant's data to a dedicated database shard for isolation, compliance, and scale.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the core package and your provider:
    dotnet add package Excalibur.EventSourcing
    dotnet add package Excalibur.EventSourcing.SqlServer # or .Postgres, etc.
  • Familiarity with event stores and multi-tenancy concepts

Overview

Tenant sharding splits data across multiple databases based on tenant identity. Each tenant is mapped to a shard -- a database instance with its own connection string, schema, and event store.

flowchart TD
subgraph Request
R[Incoming Request<br/>tenantId = acme]
end

R --> SM[ITenantShardMap]
SM -->|"acme → shard-us-east"| S1[(Shard US-East<br/>SQL Server)]
SM -->|"globex → shard-eu-west"| S2[(Shard EU-West<br/>PostgreSQL)]
SM -->|"unknown → default"| S3[(Default Shard)]

When to Use Sharding

ScenarioRecommendation
Few tenants, shared databaseNo sharding needed
Regulatory data residency (GDPR, sovereignty)Shard by region
Large tenants requiring isolationShard per tenant
100K+ events/sec aggregate throughputShard for write scalability

Quick Start

1. Define Your Shard Map

Register an ITenantShardMap that maps tenant IDs to ShardInfo records:

services.AddSingleton<ITenantShardMap>(sp =>
{
var shards = new Dictionary<string, ShardInfo>
{
["shard-us-east"] = new ShardInfo(
ShardId: "shard-us-east",
ConnectionString: "Server=us-east.db;Database=Events;..."),
["shard-eu-west"] = new ShardInfo(
ShardId: "shard-eu-west",
ConnectionString: "Server=eu-west.db;Database=Events;...",
Region: "eu-west-1"),
};

var tenantMappings = new Dictionary<string, string>
{
["acme"] = "shard-us-east",
["globex"] = "shard-eu-west",
};

var options = new ShardMapOptions
{
EnableTenantSharding = true,
DefaultShardId = "shard-us-east", // Unknown tenants go here
};

return new InMemoryTenantShardMap(shards, tenantMappings, options);
});

2. Enable Sharding and Register Provider

services.AddExcaliburEventSourcing(builder =>
{
// Step 1: Enable tenant sharding
builder.EnableTenantSharding(options =>
{
options.EnableTenantSharding = true;
options.DefaultShardId = "shard-us-east";
});

// Step 2: Register the provider-specific resolver
builder.UseSqlServerTenantEventStore();
});

That's it. IEventStore is now scoped per-request and routes to the correct shard based on the current tenant.

Core Abstractions

ShardInfo

A record describing a single shard's connection and routing metadata:

public sealed record ShardInfo(
string ShardId,
string ConnectionString,
string? SchemaName = null, // Schema-per-tenant (SQL Server: dbo, Postgres: public)
string? DatabaseName = null, // Database-per-tenant isolation
string? IndexPrefix = null, // Document/search store prefix (Elasticsearch, CosmosDB)
string? Region = null); // Geo-distributed shard hint

ITenantShardMap

Resolves shard routing for a tenant. Must be fast (< 1us target) since it's on the hot path of every data operation:

public interface ITenantShardMap
{
ShardInfo GetShardInfo(string tenantId);
}

When a tenant is not found and no DefaultShardId is configured, throws TenantShardNotFoundException.

ITenantStoreResolver<TStore>

Provider-specific resolver that creates and caches store instances per shard:

public interface ITenantStoreResolver<out TStore>
{
TStore Resolve(string tenantId);
}

Resolvers use ConcurrentDictionary<string, TStore> internally -- each shard's store is created once and cached as a singleton.

Provider Support

Each provider has a dedicated extension method that registers its ITenantStoreResolver<IEventStore>:

ProviderPackageExtension Method
SQL ServerExcalibur.EventSourcing.SqlServerbuilder.UseSqlServerTenantEventStore()
PostgreSQLExcalibur.EventSourcing.Postgresbuilder.UsePostgresTenantEventStore()
MongoDBExcalibur.EventSourcing.MongoDBbuilder.UseMongoDbTenantEventStore()
Cosmos DBExcalibur.EventSourcing.CosmosDbbuilder.UseCosmosDbTenantEventStore()
DynamoDBExcalibur.EventSourcing.DynamoDbbuilder.UseDynamoDbTenantEventStore()
FirestoreExcalibur.EventSourcing.Firestorebuilder.UseFirestoreTenantEventStore()
ElasticsearchExcalibur.Data.ElasticSearchbuilder.UseElasticSearchTenantProjectionStore<T>()

SQL Server Example

services.AddExcaliburEventSourcing(builder =>
{
builder.EnableTenantSharding(opts => opts.EnableTenantSharding = true);
builder.UseSqlServerTenantEventStore();
// Schema defaults to dbo; override via ShardInfo.SchemaName per shard
});

PostgreSQL Example

services.AddExcaliburEventSourcing(builder =>
{
builder.EnableTenantSharding(opts => opts.EnableTenantSharding = true);
builder.UsePostgresTenantEventStore();
// Schema defaults to public; override via ShardInfo.SchemaName per shard
// Each shard gets its own NpgsqlDataSource for connection pooling
});

ShardMapOptions

OptionDefaultDescription
EnableTenantShardingfalseMaster switch. When false, sharding is a no-op
DefaultShardIdnullShard ID for unknown tenants. When null, unknown tenants throw TenantShardNotFoundException

Automatic Tenant Placement

When a new tenant arrives that isn't in the shard map, ITenantPlacementStrategy selects which shard to assign it to:

public interface ITenantPlacementStrategy
{
string SelectShard(string tenantId, IReadOnlyCollection<string> availableShardIds);
}

Two built-in strategies:

StrategyAlgorithmBest For
RoundRobinPlacementStrategyCycles through shards sequentially (Interlocked counter)Even distribution when tenants arrive at steady rate
LeastLoadedPlacementStrategyPicks shard with fewest assigned tenants (atomic find-min + increment)Balancing when shard sizes vary
// Register a placement strategy
services.AddSingleton<ITenantPlacementStrategy, LeastLoadedPlacementStrategy>();

Both implementations are thread-safe and suitable for concurrent request handling.

Shared-Shard Tenant Filtering

When multiple tenants share the same physical database (shared-shard model), use ITenantFilteredEventStore to add tenant-level WHERE clauses:

public interface ITenantFilteredEventStore
{
ValueTask<IReadOnlyList<StoredEvent>> LoadByTenantAsync(
string aggregateId, string aggregateType, string tenantId, CancellationToken ct);
ValueTask<AppendResult> AppendByTenantAsync(
string aggregateId, string aggregateType, string tenantId,
IEnumerable<IDomainEvent> events, long expectedVersion, CancellationToken ct);
}

This is distinct from ITenantStoreResolver (which routes to entirely different databases). The routing layer checks for this interface via GetService<ITenantFilteredEventStore> and delegates when available.

Integration with Other Features

Partitioned Outbox

When sharding is enabled, the outbox can be partitioned per shard automatically using OutboxPartitionStrategy.PerShard:

builder.UsePartitionedOutbox(opts =>
{
opts.Strategy = OutboxPartitionStrategy.PerShard;
});

Each shard gets its own outbox table and processor. See Partitioned Outbox.

Saga Routing

TenantRoutingSagaStore decorates ISagaStore to route saga persistence to the initiating tenant's shard. Sagas are stored in the same database as their tenant's events.

Health Checks

TenantShardHealthCheck verifies shard map operational status:

services.AddHealthChecks()
.AddCheck<TenantShardHealthCheck>("tenant-shards");

Reports:

  • Healthy when all shards are reachable
  • Degraded when some shards are unreachable
  • Unhealthy when no shards are reachable

The health check iterates all registered shard IDs and probes each via ITenantStoreResolver for actual connectivity verification.

Architecture

flowchart TB
subgraph DI Container
IES[IEventStore<br/><i>Scoped</i>]
TRE[TenantRoutingEventStore]
TSR[ITenantStoreResolver&lt;IEventStore&gt;]
TSM[ITenantShardMap]
end

IES --> TRE
TRE --> TSR
TSR --> TSM

TSM -->|"ShardInfo"| R1[SqlServerEventStore<br/>shard-us-east]
TSM -->|"ShardInfo"| R2[SqlServerEventStore<br/>shard-eu-west]

subgraph Cached per ShardId
R1
R2
end

Key design points:

  • IEventStore is re-registered as Scoped (per-request) when sharding is enabled
  • TenantRoutingEventStore resolves the current tenant and delegates to the correct shard's store
  • Store instances are cached per shard ID via ConcurrentDictionary -- not per tenant
  • ADO.NET manages connection pooling per connection string automatically

Best Practices

PracticeRecommendation
Default shardSet DefaultShardId during migration; remove it once all tenants are mapped
Connection poolingADO.NET pools per connection string. Each shard ID = 1 pool
Schema isolationUse ShardInfo.SchemaName for schema-per-tenant within a shared database
Shard map sourceLoad from config, database, or service discovery. Refresh on interval for dynamic sharding
MonitoringRegister TenantShardHealthCheck and alert on unhealthy status

See Also