Skip to content

The search box knows all the secrets -- try it!

Fisher is part of the Critter Stack ecosystem.

JasperFx Logo JasperFx provides formal support for Fisher and other Critter Stack libraries. Please check our Support Plans for more details.

Multi-Tenancy ​

Fisher supports the two tenancy styles its siblings do, and one of them is a substantially better fit here than on either of them.

StyleWhat it means
SingleNo tenancy. The default.
ConjoinedOne set of tables with a tenant_id column.
Database per tenantOne SQLite file per tenant.

Conjoined tenancy ​

Documents opt in per type, or by policy:

cs
opts.Schema.For<Order>().MultiTenanted();
opts.Policies.AllDocumentsAreMultiTenanted();

Events opt in for the whole store:

cs
opts.Events.TenancyStyle = TenancyStyle.Conjoined;

WARNING

TenancyStyle.Conjoined must be set before the schema is created. The streams and events tables read it when they build their columns and their primary key, so it is a schema decision rather than a runtime one. Set it inside the DocumentStore.For / AddFisher lambda, ahead of any migration.

Conjoined events need conjoined aggregate documents

With conjoined events, every document an aggregation projection writes must be conjoined too, and Fisher refuses to build the store otherwise, with "Tenancy storage style mismatch". Two tenants may use the same stream id. A single-tenant snapshot keyed on that id alone would hold one row for both, and each tenant's projection would silently overwrite the other's. Mark the document with MultiTenanted() or [MultiTenanted], or use Policies.AllDocumentsAreMultiTenanted(). A multi-stream projection that deliberately groups across tenants says so with TenancyGrouping.AcrossTenants. Marten applies the same rule.

Then open a session for a tenant:

cs
await using var session = store.LightweightSession("acme");

Every read and every write is scoped to that tenant. The scoping is applied as a statement-level pass, not by wrapping each caller predicate — see Tenant scoping in LINQ for why that distinction was worth a bug.

Writing across tenants ​

This is where SQLite's single-writer model is the advantage rather than the constraint. One SaveChangesAsync can write several tenants' rows in one transaction:

cs
await using var session = store.LightweightSession("acme");

session.Store(new Order { /* … */ });                      // acme's
session.ForTenant("globex").Store(new Order { /* … */ });  // globex's

await session.SaveChangesAsync();                          // one transaction

The alternative is a session and a transaction per tenant, which on one file means taking the write lock N times in sequence and leaves a part-written admin operation if the process dies between two of them. See Multi-Tenanted Documents.

Database per tenant ​

Arguably SQLite's best tenancy story rather than its worst. The usual objection — database-per-tenant is heavyweight to provision — inverts here: a tenant is a file. Creating one is a file plus a migration, deleting one is deleting a file, backing one up is copying it, and one tenant's data cannot leak into another's because there is no shared table to leak through.

It also answers the sharpest structural constraint. Under conjoined tenancy every tenant contends for one write lock; under file-per-tenant they write concurrently. That makes it a performance feature as much as an isolation one, which is not true on either sibling.

A fixed set of tenants ​

cs
opts.MultiTenantedDatabases(tenants =>
{
    // The convention: one file per tenant in a directory
    tenants.InDirectory("/var/lib/app/tenants")
           .AddTenants("acme", "globex", "initech");

    // Or name a connection string explicitly
    tenants.AddTenant("special", "Data Source=/mnt/fast/special.db");
});

TIP

StoreOptions.ConnectionString becomes optional under this tenancy, because there is no store-level file — a store-level connection string would be a database nothing writes to.

Sharding: several tenants per file ​

Two tenants naming the same connection string share a file. Combined with conjoined tenancy, that is sharded tenancy — a pool of files with tenants co-located in each — and it is the middle ground between one file for everyone and one file each.

cs
opts.Events.TenancyStyle = TenancyStyle.Conjoined;

opts.MultiTenantedDatabases(tenants => tenants
    .AddTenant("acme", "Data Source=/var/lib/app/shard-one.db")
    .AddTenant("globex", "Data Source=/var/lib/app/shard-one.db")
    .AddTenant("initech", "Data Source=/var/lib/app/shard-two.db"));

Fisher builds one database per file, not per tenant, so AllDatabases() reports the files there are, co-located tenants share one connection pool, and a shared file reports no TenantId of its own — a file holding two tenants' data cannot honestly say whose it is.

WARNING

Conjoined tenancy is what makes this safe, and Fisher refuses the store without it. Two tenants sharing a file with no tenant_id column to be told apart by are not two tenants — they are one tenant with two names, writing the same rows. So a shared file requires options.Events.TenancyStyle = TenancyStyle.Conjoined and MultiTenanted() on every document type, and a store that says otherwise fails to build with the tenants and the file named.

The event-store half is required even for a store that never appends an event: the event tables are created by every migration, and an append does not need its event type registered, so "does this store use events" has no honest answer at configuration time. One line and an unused column is the price of a rule that cannot guess wrong.

A document type nothing registered at configuration time is caught the first time it is read or written, which is the first moment it exists at all.

TIP

The trade against one file per tenant is the one SQLite always poses: tenants sharing a file share its write lock. Sharding buys you fewer files to back up and fewer connection pools, at the cost of serialising the writers inside each shard.

Tenants that appear at runtime ​

Provisioning a tenant is cheap enough to do on first use, which makes "a tenant appears without a restart" a reasonable offer rather than an operational event:

cs
// Any tenant id resolves to <directory>/<id>.db, whether the file exists yet or not.
opts.MultiTenantedDatabasesInDirectory("/var/lib/app/tenants");

// Or push your own set
opts.MultiTenantedDatabasesFrom(new MyTenantSource());

Implement ITenantSource to drive it from your own tenants table:

cs
public sealed record TenantRegistration(string TenantId, string ConnectionString, bool IsActive = true);

public interface ITenantSource
{
    bool TryFind(string tenantId, out TenantRegistration registration);
    ValueTask<IReadOnlyList<TenantRegistration>> AllAsync(CancellationToken token = default);

    // Optional. Call it when a tenant stops being routable, so the tenancy drops its cached database.
    Action<string>? OnTenantRevoked { get => null; set { } }
}

TIP

TryFind is synchronous and AllAsync is not, for a reason: the hot path — resolving a tenant while opening a session — has to answer without I/O, which the directory convention manages trivially. Enumerating every tenant is a startup and daemon concern, where an await is available.

Tenant ids under the directory convention ​

Because the directory convention turns a tenant id into a file name, Fisher constrains what an id may be. Letters, digits, ., _ and -, at most 200 characters:

cs
store.LightweightSession("acme");        // fine
store.LightweightSession("acme-co");     // fine
store.LightweightSession("acme.co");     // fine

store.LightweightSession("../escape");   // ArgumentException
store.LightweightSession("/var/tmp/x");  // ArgumentException
store.LightweightSession("has space");   // ArgumentException
store.LightweightSession("CON");         // ArgumentException — a device name on Windows

WARNING

An id is refused, never cleaned up. Stripping the unsafe characters would map two different tenant ids onto one database file, which is precisely the failure database-per-tenant exists to make impossible — and it would be silent. If your tenant ids come from somewhere that allows more than this (a subdomain, a header, a customer-supplied name), map them to safe ids yourself and keep the mapping.

This matters most where it is easiest to miss: any tenant id resolves under this convention, so there is no registration step acting as a gate, and framework tenant-id detection commonly forwards a header, route value or subdomain straight into ForTenant(...). MultiTenantedDatabases(...)'s InDirectory(...) convention applies the same rule, so an id cannot be accepted in configuration and refused at runtime.

MultiTenantedDatabasesFrom(...) with your own ITenantSource, and the tenant registry below, build connection strings rather than paths — so this rule does not apply to them, and validating what you put in a connection string is yours.

The three supplied sources differ deliberately:

SourceUnknown tenant idThe set is
DirectoryTenantSourceResolves it, creating the file on first use. Enumeration reports only files that exist.a convention
InMemoryTenantSourceRefuses it. For an application pushing its own tenants list.one process's memory
MasterTableTenantSourceRefuses it.a record — see below

The tenant registry ​

The durable form: one small SQLite database holding a fi_tenants table that names every tenant and where its data lives. Marten's master-table tenancy, for a store whose tenants are files.

cs
var tenants = opts.MultiTenantedDatabasesInRegistry(x =>
{
    x.ConnectionString = "Data Source=/var/lib/app/tenants.db";

    // A registry store has no store-level file, so the default tenant has to be named here — it is
    // read while the store is being built, before anything could have read the table.
    x.SeedDatabases.RegisterDefault("Data Source=/var/lib/app/tenants/main.db");
});

// …and at any time afterwards, from anywhere
await tenants.AddTenantAsync("acme", "Data Source=/var/lib/app/tenants/acme.db");
await tenants.SuspendTenantAsync("acme");
await tenants.ResumeTenantAsync("acme");
await tenants.ForgetTenantAsync("acme");

Reach it later through ((DynamicTenancy)store.Tenancy).Source.

Unlike the other two, the set survives a restart and is shared between processes — which is the whole reason to reach for it. A tenant added through this instance is usable by the very next session; one added to the registry by another process appears on the next refresh.

WARNING

A tenant this process has not read does not resolve. ITenancy.DatabaseFor is reached from the synchronous OpenSession and has no await to offer, so Fisher will not read the control table on the session path — Marten answers the same question with GetAwaiter().GetResult() and Fisher deliberately does not. The async daemon refreshes every minute; ApplyAllConfiguredChangesToDatabaseAsync, db-apply and source.RefreshAsync() all refresh on demand.

TIP

SQLite has no schemas, so SchemaName folds into the table name as it does everywhere else in Fisher: fi_tenants, or reporting_fi_tenants under a logical schema called reporting. Two logical stores can therefore share one registry file and keep separate tenant lists.

Re-adding a suspended tenant does not resume it. The upsert corrects the connection string and leaves the disabled flag alone, matching Marten — resuming as a side effect of correcting a connection string would silently undo a deliberate suspension.

Migration per tenant ​

A new tenant's file is migrated the first time a connection is opened to it — not when the tenant is resolved, because ITenancy.DatabaseFor is reached from the synchronous OpenSession and a migration is asynchronous.

TIP

The result is not cached until it succeeds. A transient failure remembered as done would leave the tenant permanently unusable with nothing to say why.

Migrating an existing set runs sequentially, and reports per database:

cs
await store.ApplyAllConfiguredChangesToDatabaseAsync();

A failure part way leaves mixed versions whatever it throws, so TenantMigrationException reports which databases are current. Sequential rather than parallel because each migration takes its own file's write lock — parallelism wins nothing on the DDL and holds N connections against a pool ceiling that sizes one file.

Suspending and forgetting tenants ​

A tenant is switched off through the source, which is what owns the set:

cs
var source = new DirectoryTenantSource("/var/lib/app/tenants");
opts.MultiTenantedDatabasesFrom(source);

source.Suspend("acme");                    // DisabledTenantException on use
source.Resume("acme");

InMemoryTenantSource spells the same thing SetActive(id, false), and Remove(id) drops it entirely; MasterTableTenantSource spells them SuspendTenantAsync / ResumeTenantAsync / ForgetTenantAsync, and those changes are written to the registry rather than held in memory.

TIP

Suspending takes effect immediately, including for a tenant this store has already opened a session for. That is not free — the tenancy caches a database per tenant — so a source tells it through ITenantSource.OnTenantRevoked, which the tenancy sets and which every supplied source raises.

Forgetting a tenant a process is finished with — which releases its pooled connections and their file handles — is on the tenancy:

cs
var tenancy = (DynamicTenancy)store.Tenancy;
await tenancy.ForgetTenantAsync("acme");

WARNING

Fisher suspends; it never deletes. Deleting a tenant here means deleting a file — the cheapest deprovisioning of any Critter Stack store, and the most irreversible — and Fisher cannot know whether that file is backed up. Remove the file yourself.

That holds for the registry too: ForgetTenantAsync removes the tenant's row, and the .db file stays exactly where it was. Re-registering the tenant finds its data again. To erase a tenant's rows and keep the file, use store.Advanced.DeleteAllTenantDataAsync(id).

DisabledTenantException is distinct from UnknownTenantException on purpose: "switched off" and "never heard of it" are different operational situations, and an application handling one should not have to guess which it got.

UnknownTenantException derives from JasperFx.MultiTenancy.UnknownTenantIdException, the type Marten, Polecat and Wolverine all throw for the same condition — so store-agnostic code and a Wolverine OnException<UnknownTenantIdException>() policy catch it here too. The message stays Fisher's, which names the tenants this store does know.

The daemon under database-per-tenant ​

The async daemon runs one instance per tenant database:

cs
var daemons = await store.BuildProjectionDaemonsAsync();   // all of them
var one = await store.BuildProjectionDaemonAsync("acme");  // one

AddAsyncDaemon() hosts them all. N daemons over N files do not contend — the same property that makes this tenancy a performance feature. Under DynamicMultiple tenancy the hosted service polls for new tenants every minute; a new tenant's sessions work immediately either way, since resolution does not go through the hosted service.

TIP

Shard names did not have to become (projection, tenant) pairs. fi_event_progression lives in each tenant's own file, so every database already has its own high-water mark and its own progress row per shard.

Cleaning ​

ResetAllDataAsync and the whole IDocumentCleaner surface loop every database. Cleaning only the default would leave every other tenant's data behind while reporting success — and the caller most likely to hit that is a test fixture.

Why not ATTACH? ​

ATTACH DATABASE would let one connection see several tenants. It is not used, because an attachment has per-connection lifecycle to re-establish on every pooled checkout — exactly what folding the logical schema into the table prefix exists to avoid.

Released under the MIT License.