Skip to content

Tenancy end to end

A tenant passes through three separate decisions before any data is read or written. Arc and the storage integrations each own part of it, and the defaults differ per storage. This page follows one tenant through all of them.

Request

1. Selection: resolve the tenant id

2. Membership: your code allows it

3. Placement: which database and identity

DecisionOwnerDetails
SelectionArcResolving tenant IDs from a header, claim, subdomain or your own resolver
MembershipYour applicationReject tenants the caller may not use, before any storage is touched
PlacementThe storage integrationNaming by default, or connection resolution when your platform decides

Arc resolves a tenant and exposes it. It does not authorize it. That check is yours.

Resolve the tenant from a claim and nothing else needs configuring for selection:

{
"Cratis": {
"Arc": {
"Tenancy": {
"ResolverType": "Claim",
"ClaimType": "tenant_id"
}
}
}
}

Each storage then reacts differently to the same tenant:

StorageWhat a named tenant acme getsDefault or unset tenant
MongoDBThe database <Database>+acme. Collections in that scope come from itThe bare <Database>
Entity Framework CoreNothing. Every tenant uses the one configured connection string. Isolation is yours to buildThe same connection
ChronicleThe event store namespace acme, used verbatimThe Default namespace

MongoDB and Chronicle separate tenants for you. EF Core does not, which is the usual surprise. A tenant context alone does not make an ordinary DbContext tenant-aware.

Unset and default tenants deliberately select the default database or namespace and are not rejected. If your application must not serve data without a tenant, reject the request yourself.

Inject ITenantIdAccessor where a service needs to branch on the tenant. This reads context only and does not authorize it:

using Cratis.Arc.Tenancy;
public class CustomerService(ITenantIdAccessor tenantIdAccessor)
{
public bool HasTenant => tenantIdAccessor.Current != TenantId.NotSet;
}

For off-request work, select the tenant first as described in tenant context access.

Default naming suits a team that owns its database server. When a platform provisions each tenant’s database, on its own server and with its own credentials, switch to connection resolution. When a connection is needed, Arc describes what it is for (the storage, the tenant and the purpose), and a resolver answers with the parts it decides.

Resolution has two slots with one shape: Connection decides where to connect, and Identity decides who connects. Nothing changes until you configure one.

This places every tenant in a database named after it, for MongoDB and EF Core alike:

{
"Cratis": {
"Arc": {
"ConnectionResolution": {
"Connection": {
"Resolver": "Template",
"Template": { "Database": "tenant-{tenant}" }
}
}
}
}
}

Keep the server and database as configured, and resolve only who connects. A short-lived token from your platform stays out of configuration:

{
"Cratis": {
"Arc": {
"ConnectionResolution": {
"Identity": {
"Resolver": "Http",
"Http": {
"Endpoint": "https://platform.example/connections",
"Headers": { "X-Api-Key": "from-your-secret-store" }
}
}
}
}
}
}

The request carries the storage, so one endpoint can serve MongoDB and EF Core. It is a plain REST contract you can implement in any language: see the connection resolution contract.

The resolved parts apply differently to each storage:

MongoDBSQL ServerPostgreSQL
ConnectionStringReplaces ServerReplaces the connection stringReplaces the connection string
DatabaseReplaces the database name, including for the default tenantInitial CatalogDatabase
Identity passwordA SCRAM credentialUser ID and PasswordUsername and Password
Identity access tokenA MONGODB-OIDC credential that refreshes through the resolverSqlConnection.AccessToken (Entra ID)Used as the password
SchemaNot usedNot appliedNot applied

Two nuances matter most:

  • A resolved Database replaces the default naming completely. Tenant acme is no longer <Database>+acme, and the default tenant resolves as Default. Data stored under the old names stays there, so treat switching as a data migration.
  • EF Core resolves when a connection opens, not when a context is created. A pooled context never reuses a connection resolved for an earlier tenant.

See MongoDB and Entity Framework Core for each storage. A storage that configures its own ConnectionResolution replaces the application-wide one as a whole. They are not merged.

Resolution is for the Runtime purpose unless code says otherwise. Run schema changes inside a purpose scope so your resolver can hand out an identity that may alter the schema:

using (ConnectionPurposeScope.Begin(ConnectionPurpose.Schema))
{
await dbContext.Database.MigrateAsync();
}

Chronicle: the kernel decides its own placement

Section titled “Chronicle: the kernel decides its own placement”

The Chronicle integration maps the tenant to a namespace and nothing more. Where the namespace’s events and read models live is decided by the Chronicle kernel, which is configured on its own, with the same resolvers and the same REST contract:

  • Arc’s Cratis:Arc:ConnectionResolution does not change where the kernel stores anything.
  • The kernel’s storage.connectionResolution does not change what your Arc application connects to.
  • A single platform endpoint can answer both. Arc sends the tenant as its scope, and the kernel sends kind, eventStore and namespace.

Keep them aligned when your application reads read models Chronicle writes. Chronicle projects into its read model database for the namespace. Your application reads through MongoDB or EF Core and its own resolution. Both must reach the same database. By default MongoDB lines up: Arc’s <Database>+<tenant> equals Chronicle’s read model name when Database is the event store name, and both use the bare name for the default tenant. With resolution, point both at the same placement. Reads that reach the wrong database come back empty rather than failing.

Read Chronicle’s Tenancy for the kernel side, including how MongoDB and SQL differ in naming, limits and permanent bindings.

You wantUse
One database server, a database per tenant named by conventionThe defaults for MongoDB and Chronicle. Add your own isolation for EF Core
Every tenant in a database named by a ruleA Template resolver on Arc, and the same rule on the kernel
Tenant databases provisioned by a platform, on separate serversAn Http resolver on Arc and on the kernel
Rotating credentials, managed identities or short-lived tokensAn Identity slot only, on both
Migrations as a more privileged userConnectionPurpose.Schema, and a resolver that answers it
  • Verify membership before selecting a tenant, because Arc selects but does not authorize.
  • Do not capture tenant-scoped collections, contexts or services in singletons.
  • Include the tenant id in cache keys, logs and telemetry.
  • Test an unset tenant, the default tenant and two named tenants, including concurrent requests and reused scopes.
  • Check where each tenant’s data actually lands, and not only that the tenant id was resolved.