Tenant Data Sharding
Route each tenant's data to a dedicated database shard for isolation, compliance, and scale.
Before You Start
- .NET 10.0
- Install the core package and your provider:
dotnet add package Excalibur.EventSourcingdotnet 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.
When to Use Sharding
| Scenario | Recommendation |
|---|---|
| Few tenants, shared database | No sharding needed |
| Regulatory data residency (GDPR, sovereignty) | Shard by region |
| Large tenants requiring isolation | Shard per tenant |
| 100K+ events/sec aggregate throughput | Shard for write scalability |
Quick Start
1. Define Your Shard Map
Register an ITenantShardMap that maps tenant IDs to ShardInfo records.
Declare every isolation coordinate the store you are sharding uses — the connection string plus the schema, database or index prefix, depending on the provider. A shard that omits one is rejected when it resolves rather than filled in from the deployment default, because two shards that both omit the same coordinate would resolve to the same physical location and place their tenants' data together. State the value explicitly even when it is the default for your deployment.
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;...",
SchemaName: "dbo"),
["shard-eu-west"] = new ShardInfo(
ShardId: "shard-eu-west",
ConnectionString: "Server=eu-west.db;Database=Events;...",
SchemaName: "dbo",
Region: "eu-west-1"),
};
var tenantMappings = new Dictionary<string, string>
{
["acme"] = "shard-us-east",
["globex"] = "shard-eu-west",
};
var options = new ShardMapOptions
{
DefaultShardId = "shard-us-east", // Unknown tenants go here
};
return new InMemoryTenantShardMap(shards, tenantMappings, options);
});
2. Enable Sharding and Register Provider
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
// Step 1: Enable tenant sharding
builder.EnableTenantSharding(options =>
{
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.
Client lifetime
A resolver that builds its own driver client per shard -- DynamoDB, Cosmos DB, MongoDB -- owns that
client's lifetime. Each is registered as a container singleton and implements IAsyncDisposable, so
disposing the host's service provider disposes every store and every client the resolver created,
releasing the per-shard connection pools. Nothing else holds a reference to those clients, so a host
that never disposes its provider keeps them open for the life of the process.
After disposal Resolve throws ObjectDisposedException rather than handing back a store over a
closed client.
Provider Support
Each provider has a dedicated extension method that registers its ITenantStoreResolver<IEventStore>:
| Provider | Package | Extension Method |
|---|---|---|
| SQL Server | Excalibur.EventSourcing.SqlServer | builder.UseSqlServerTenantEventStore() |
| PostgreSQL | Excalibur.EventSourcing.Postgres | builder.UsePostgresTenantEventStore() |
| MongoDB | Excalibur.EventSourcing.MongoDB | builder.UseMongoDbTenantEventStore() |
| Cosmos DB | Excalibur.EventSourcing.CosmosDb | builder.UseCosmosDbTenantEventStore() |
| DynamoDB | Excalibur.EventSourcing.DynamoDb | builder.UseDynamoDbTenantEventStore() |
| Firestore | Excalibur.EventSourcing.Firestore | builder.UseFirestoreTenantEventStore() |
| Elasticsearch | Excalibur.Data.ElasticSearch | builder.UseElasticSearchTenantProjectionStore<T>() |
SQL Server Example
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.EnableTenantSharding(opts => opts.DefaultShardId = "shard-us-east");
builder.UseSqlServerTenantEventStore();
// Schema defaults to dbo; override via ShardInfo.SchemaName per shard
}));
PostgreSQL Example
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(builder =>
{
builder.EnableTenantSharding(opts => opts.DefaultShardId = "shard-us-east");
builder.UsePostgresTenantEventStore();
// Schema defaults to public; override via ShardInfo.SchemaName per shard
// Each shard gets its own NpgsqlDataSource for connection pooling
}));
ShardMapOptions
| Option | Default | Description |
|---|---|---|
DefaultShardId | null | Shard ID for unknown tenants. When null, unknown tenants throw TenantShardNotFoundException |
DefaultShardId covers an unknown tenant, never a missing oneAn unknown tenant is a real identifier the shard map has no entry for; that is what the default shard is for. A missing tenant — a context that has resolved no tenant at all, typically a background worker or hosted service running outside a request — is refused instead.
The ambient-context helpers map.CurrentShard(context) and IAmbientTenantStoreResolver<TStore>.ResolveCurrent()
read the tenant through TenantScope.FromContext, so they throw TenantRequiredException before the
shard map is consulted. Routing a missing tenant would hand the caller the default shard's store and let it
read and write that shard's tenant data with nothing raised.
Establish the tenant for such work (TenantContextHolder.BeginScope(tenantId)) and drive it once per
tenant. A caller that genuinely belongs to no tenant states that with a value — supply
UntenantedContext.Instance, whose tenant term is the reserved untenanted partition and routes like any
other key. An operation spanning every tenant is not expressible through these helpers, which resolve one
tenant's store; reach it through a store method that is estate-wide by name.
Sharding is enabled by calling builder.EnableTenantSharding(...). Not calling the method leaves stores registered with their default lifetime and bypasses the tenant-routing decorator entirely.
Tenants Not in the Shard Map
The shard map is fixed when you register it. There is no automatic placement of a tenant the map does not
name: a tenant arriving for the first time is routed to ShardMapOptions.DefaultShardId if you configured
one, and otherwise raises TenantShardNotFoundException.
builder.EnableTenantSharding(options =>
{
options.DefaultShardId = "shard-1"; // unknown tenants land here
// leave it null to refuse an unmapped tenant instead
});
Assign a new tenant by adding it to the mapping the shard map is built from, in whatever system of record owns your tenant roster. Do that before the tenant's first request, because the choice has to be the same in every process and across every restart — a shard chosen per-process would split one tenant's data across shards, and nothing downstream would report it.
Shared-Shard Tenant Filtering
When multiple tenants share the same physical database (shared-shard model), the event store filters by tenant automatically. There is no interface to implement and nothing to register: the store binds a tenant term on its read and write paths from the ambient tenant scope, so a scoped read cannot observe another tenant's events.
// Nothing tenant-specific here. The ambient scope supplies the tenant term.
var events = await eventStore.LoadAsync(aggregateId, aggregateType, fromVersion, ct);
Two properties of that filtering are worth knowing, because they decide what your schema must look like:
- The tenant column is total. Every row carries a tenant value; a row belonging to no tenant carries
the reserved
__untenanted__sentinel rather thanNULL. "Untenanted" is a value, not a missing one, so "this row is deliberately global" and "the writer forgot the tenant" cannot be confused. - An unscoped write is unconstructable. The store routes writes through a keyed tenant partition, so it binds either the resolved tenant or the sentinel — it cannot omit the term.
This is distinct from ITenantStoreResolver, which routes to entirely different databases. Use the
resolver when tenants are physically separated; rely on the automatic filtering above when they share a
shard.
Integration with Other Features
Outbox Integration
When sharding is enabled, the event sourcing outbox uses the unified IOutboxStore and ITransactionalOutboxWriter interfaces. Configure per-aggregate staging strategy to control how integration events are staged:
services.AddExcalibur(excalibur => excalibur.AddEventSourcing(es =>
{
es.AddRepository<Order>(id => new Order(id), opts =>
{
opts.OutboxStagingStrategy = OutboxStagingStrategy.Transactional;
});
}));
The PartitionKey on OutboundMessage is set to the tenant ID (when available) or aggregate ID, enabling downstream partition-aware processing. See Event Sourcing Outbox Integration.
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
Key design points:
IEventStoreis re-registered as Scoped (per-request) when sharding is enabledTenantRoutingEventStoreresolves 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
| Practice | Recommendation |
|---|---|
| Default shard | Set DefaultShardId during migration; remove it once all tenants are mapped |
| Connection pooling | ADO.NET pools per connection string. Each shard ID = 1 pool |
| Schema isolation | Use ShardInfo.SchemaName for schema-per-tenant within a shared database |
| Shard map source | Load from config, database, or service discovery. Refresh on interval for dynamic sharding |
| Monitoring | Register TenantShardHealthCheck and alert on unhealthy status |
See Also
- Event Store Providers -- Provider-specific setup
- Outbox Pattern -- Partitioned outbox with sharding
- Projections -- Parallel catch-up with sharding