MultiTenant Stores
A MultiTenant store is responsible for retrieving information about a tenant based on an identifier string determined
by MultiTenant strategies. The retrieved information is then used to create an ITenantInfo object which
provides the current tenant information to your app.
MultiTenant supports several "out-of-the-box" stores for resolving the tenant. Custom stores can be created by
implementing IMultiTenantStore.
Custom TenantInfo Support
MultiTenant stores support custom ITenantInfo implementations, but complex types may require special
handling. For best results, ensure the type works well with the underlying store approach—for example, that it can be
serialized from JSON for the configuration store if using JSON file configuration sources.
Stores never mutate a tenant info instance. Id never changes; Identifier may change only by passing a new instance
to UpdateAsync, never in place on an instance a store already holds. Constructor-only immutable implementations are
supported by every store; ConfigurationStore and EchoStore, which have to construct instances themselves, take a
factory for such types (see below). See the tenant info contract for details.
The HttpRemoteStore and DistributedCacheStore create tenant instances with System.Text.Json. A constructor-only
type works with these stores when it has exactly one public constructor whose parameter names match its property names
(for example a primary constructor, as in the tenant info contract example). If the
type has more than one public constructor, mark the one to use with [JsonConstructor]; otherwise deserialization
throws a NotSupportedException.
The examples in this documentation use the TenantInfo basic implementation.
IMultiTenantStore and Custom Stores
If the provided MultiTenant stores are not suitable then a custom store can be created by
implementing IMultiTenantStore<TTenantInfo>. The library will set the type parameter TTenantInfo to match the type
parameter passed to AddMultiTenant<TTenantInfo> at compile time. The interface defines AddAsync, UpdateAsync,
RemoveAsync, GetByIdentifierAsync, GetAsync, and GetAllAsync methods. GetByIdentifierAsync
and GetAsync should return null if there is no suitable tenant match.
A custom implementation of IMultiTenantStore<TTenantInfo> can be registered by calling WithStore<TStore>
after AddMultiTenant<TTenantInfo> in the ConfigureServices method of the Startup class. WithStore<TStore> uses
dependency injection along with any passed parameters to construct the implementation instance. Alternative overloads
accept a service lifetime, a factory method, and/or other parameters for more customization. The library internally
decorates any IMultiTenantStore<TTenantInfo> at runtime with a wrapper providing basic logging and exception handling.
Custom store implementations should contain minimal logging or validation logic. These are handled via the wrapper store used during runtime for consistency.
// register a custom store with the templated method
builder.Services.AddMultiTenant<TenantInfo>()
.WithStore<MyStore>(ServiceLifetime.Singleton, myParam1, myParam2)...
// or register a custom store with the non-generic method, which accepts a factory method
builder.Services.AddMultiTenant<TenantInfo>()
.WithStore(ServiceLifetime.Singleton, sp => new MyStore())...
Using Multiple Stores
Multiple stores can be used, and for each strategy returning a non-null identifier the stores are checked in the order registered until a matching tenant is resolved. Keep in mind that if multiple strategies are used it is possible for a store to be checked multiple times during tenant resolution.
Accessing the Store at Runtime
MultiTenant stores are registered in the dependency injection system under the
IMultiTenantStore<TTenantInfo> service type.
If multiple stores are registered a specific one can be retrieved by requesting an
IEnumerable<IMultiTenantStore<TTenantInfo>> and filtering to the specific implementation type:
public MyService(IEnumerable<IMultiTenantStore<TenantInfo>> stores)
{
_configurationStore = stores.OfType<ConfigurationStore<TenantInfo>>().Single();
}
Getting All Tenants from Store
If implemented, GetAllAsync will return an IEnumerable<TTenantInfo> listing of all tenants in the store.
Currently InMemoryStore, ConfigurationStore, EFCoreStore, and HttpRemoteStore implement GetAllAsync.
Pagination of GetAllAsync
An overload to GetAllAsync(int take, int skip) exists to optionally allow take and skip parameters for pagination
support if needed when iterating through a large number of tenants or retrieving from a remote source.
In-Memory Store
NuGet package: Finbuckle.MultiTenant
Uses a ConcurrentDictionary<string, TenantInfo> as the underlying store. See the
web api sample project for an example of
using the in-memory store.
Configure by calling WithInMemoryStore after AddMultiTenant<TTenantInfo>. By default, the store is empty and the
tenant identifier matching is case-insensitive. Case-insensitive is generally preferred. An overload
of WithInMemoryStore accepts an Action<InMemoryStoreOptions> delegate to configure the store further:
// set up a case-insensitive in-memory store.
builder.Services.AddMultiTenant<TenantInfo>()
.WithInMemoryStore()...
// or make it case-sensitive and add tenants.
builder.Services.AddMultiTenant<TenantInfo>()
.WithInMemoryStore(options =>
{
options.IsCaseSensitive = true;
options.Tenants.Add(new TenantInfo { Id = "1", Identifier = "initech" });
options.Tenants.Add(new TenantInfo { Id = "2", Identifier = "acme" });
})...
Configuration Store
NuGet package: Finbuckle.MultiTenant
Uses an
app's configuration as
the underlying store. See
the multi-tenant Identity sample project for
an example of using this store with appsettings.json.
This store is case-insensitive when retrieving tenant information by tenant identifier.
This store is read-only and calls to AddAsync, UpdateAsync, and RemoveAsync will throw
a NotImplementedException. However, if the app is configured to reload its configuration if the source changes,
e.g. appsettings.json is updated, then the MultiTenant store will reflect the change.
Configure by calling WithConfigurationStore after AddMultiTenant<TTenantInfo>. By default, it will use the root
configuration object and search for a section named "Finbuckle:MultiTenant:Stores:ConfigurationStore". An overload
of WithConfigurationStore allows for a different base
configuration object or section name if needed.
// register to use the default root configuration and section name.
builder.Services.AddMultiTenant<TenantInfo>()
.WithConfigurationStore()...
// or use a different configuration object and section name
builder.Services.AddMultiTenant<TenantInfo>()
.WithConfigurationStore(builder.Configuration, "MyApp:Tenants")...
The configuration section should use this JSON format shown below. Any fields in the Defaults section will be
automatically copied into each tenant unless the tenant specifies its own value (collections are the exception; see
below). For a custom implementation
of TenantInfo, its properties are mapped from the JSON automatically.
{
"Finbuckle:MultiTenant:Stores:ConfigurationStore": {
"Defaults": {
"ConnectionString": "default_connection_string"
},
"Tenants": [
{
"Id": "unique-id-0ff4daf",
"Identifier": "tenant-1",
"Name": "Tenant 1 Company Name",
"ACustomProperty": "VIP Customer"
},
{
"Id": "unique-id-ao41n44",
"Identifier": "tenant-2",
"Name": "Name of Tenant 2",
"ConnectionString": "tenant_specific_connection_string"
}
]
}
}
Each tenant is created by binding the Defaults section and then the tenant's own section onto a new instance, so:
- scalar values in the tenant section replace the value from
Defaults, - dictionary entries are merged by key, with tenant keys replacing matching default keys,
- collection (list or array) entries are appended: the tenant's entries follow the entries from
Defaults. For example,"Tags": ["a", "b"]inDefaultsand"Tags": ["c"]in a tenant gives that tenant["a", "b", "c"]. To give a tenant a different collection rather than an extended one, leave the property out ofDefaults.
Properties are bound with the standard configuration binder, so they need a setter (set or init, public or
non-public). For a constructor-only immutable tenant info type the store throws a MultiTenantException explaining the
problem; pass a factory instead:
builder.Services.AddMultiTenant<AppTenantInfo>()
.WithConfigurationStore(builder.Configuration, "Finbuckle:MultiTenant:Stores:ConfigurationStore",
config => new AppTenantInfo(config["Id"]!, config["Identifier"]!, config["ConnectionString"]));
The factory receives a single configuration with the tenant's own keys layered over the Defaults section. Scalar
values and dictionary keys behave as above, but collection entries present in both are overlaid by index rather
than appended, as with any layered .NET configuration: ["a", "b"] in Defaults and ["c"] in a tenant reads as
["c", "b"]. Read collections with care, or keep them out of Defaults, if this matters to your app.
The singleton store retains the factory and invokes it for each tenant during initialization and configuration reloads. The factory must support concurrent calls. Avoid capturing scoped services or unsynchronized mutable state. Read the supplied configuration during the call and return a fully constructed tenant with stable identity values; the configuration is disposed after the factory returns and must not be retained for ongoing use.
Every tenant must end up with a non-empty Id and Identifier, whether bound or created by a factory; otherwise the
store throws a MultiTenantException naming the offending configuration section.
EFCore Store
NuGet package: Finbuckle.MultiTenant.EntityFrameworkCore
Uses an Entity Framework Core database context as the backing store.
Case sensitivity is determined by the underlying EF Core database provider.
The database context must derive from EFCoreStoreDbContext. The EFCoreStore carefully avoids tracking issues by
using no-tracking queries and detaching entities after store operations. If your app uses the
EFCoreStoreDbContext directly it should be aware of these issues.
This database context is not itself multi-tenant, but rather contains the details of all tenants. It will often be a standalone database separate from any tenant database(s) and will have its own connection string.
Configure by calling WithEFCoreStore<TEFCoreStoreDbContext,TenantInfo> after AddMultiTenant<TTenantInfo> and
provide types for the store's database context generic parameter:
// configure dbcontext `MultiTenantStoreDbContext`, which derives from `EFCoreStoreDbContext`
builder.Services.AddMultiTenant<TenantInfo>()
.WithEFCoreStore<MultiTenantStoreDbContext,TenantInfo>()...
Http Remote Store
NuGet package: Finbuckle.MultiTenant
Sends the tenant identifier, provided by the multi-tenant strategy, to an HTTP(S) endpoint to get a TenantInfo object
in return.
This store is usually case-insensitive when retrieving tenant information by tenant identifier, but the remote server might be more restrictive.
Make sure the tenant info type will support basic JSON serialization and deserialization via System.Text.Json.
This store attempts to deserialize the tenant using
the System.Text.Json web defaults.
For a successful request, the store expects a 2xx response code and a JSON body with properties Id, Identifier,
Name, and other properties which will be mapped into a TenantInfo object with the type
passed to AddMultiTenant<TTenantInfo>.
Any non-2xx response code results in a null TenantInfo.
This store is read-only. AddAsync, GetAsync, UpdateAsync, and RemoveAsync throw a
NotImplementedException.
Configure by calling WithHttpRemoteStore after AddMultiTenant<TTenantInfo>. A URI template string must be passed to the
method. At runtime the tenant identifier replaces the substring {__tenant__} in the URI template. If the template
provided does not contain {__tenant__}, the identifier is appended to the template. An overload
of WithHttpRemoteStore allows for a lambda function to further configure the internal HttpClient:
// append the identifier to the provided url
builder.Services.AddMultiTenant<TenantInfo>()
.WithHttpRemoteStore("https://remoteserver.com/")...
// or template the identifier into a custom location
builder.Services.AddMultiTenant<TenantInfo>()
.WithHttpRemoteStore("https://remoteserver.com/{__tenant__}/getinfo")...
// or modify the underlying `HttpClient` with a custom message handler and settings
builder.Services.AddMultiTenant<TenantInfo>()
.WithHttpRemoteStore("https://remoteserver.com/", httpClientBuilder =>
{
httpClientBuilder.AddHttpMessageHandler<MyCustomHeaderHandler>();
httpClientBuilder.ConfigureHttpClient( client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
});
// or add Polly support
// via https://www.hanselman.com/blog/AddingResilienceAndTransientFaultHandlingToYourNETCoreHttpClientWithPolly.aspx
builder.Services.AddMultiTenant<TenantInfo>()
.WithHttpRemoteStore("https://remoteserver.com/", httpClientBuilder =>
{
httpClientBuilder.AddTransientHttpErrorPolicy(policyBuilder => policyBuilder.RetryAsync(2));
});
Distributed Cache Store
NuGet package: Finbuckle.MultiTenant
Uses the distributed cache
mechanism. The distributed cache can use Redis, SQL Server, NCache, or an in-memory (for testing purposes)
implementation. A sliding expiration is also supported. The store does not interact with any other stores by default.
Make sure the tenant info type will support basic JSON serialization and deserialization via System.Text.Json.
This store serializes and deserializes the tenant with the default System.Text.Json options.
The tenant identifier is part of the cache key, so case sensitivity when retrieving tenant information by tenant identifier is determined by how the cache implementation compares keys. The in-memory and Redis caches compare keys exactly, so lookups are case-sensitive.
Each tenant info instance is actually stored twice in the cache, once using the Tenant ID as the key and another using
the Tenant Identifier as the key. Calls to AddAsync, UpdateAsync, and RemoveAsync will keep these dual
cache entries synced.
This store does not implement GetAllAsync.
Configure by calling WithDistributedCacheStore after AddMultiTenant<TTenantInfo>. By default, entries do not expire,
but a TimeSpan can be passed to be used as a sliding
expiration:
// use the default configuration with no sliding expiration.
services.AddMultiTenant<TenantInfo>()
.WithDistributedCacheStore()...
// or set a 5 minute sliding expiration.
services.AddMultiTenant<TenantInfo>()
.WithDistributedCacheStore(TimeSpan.FromMinutes(5));
Echo Store
NuGet package: Finbuckle.MultiTenant
The Echo Store serves as a simple, read-only store that directly returns a new tenant instance based on the given identifier without any additional settings. It's particularly suited for applications that require a simple, immediate method for tenant identification without the need for persistence, such as during testing phases or in environments where tenant information is static and predefined elsewhere.
This store is read-only. AddAsync, UpdateAsync, RemoveAsync, and GetAllAsync throw a
NotImplementedException.
Configure by calling WithEchoStore after AddMultiTenant<TTenantInfo>.
services.AddMultiTenant<TenantInfo>()
.WithEchoStore();
The store has to construct tenant instances itself. By default it does so by reflection, which requires Id and
Identifier to have a setter (set or init, public or non-public). For a constructor-only immutable tenant info type
the store throws a MultiTenantException explaining the problem; pass a factory instead:
services.AddMultiTenant<AppTenantInfo>()
.WithEchoStore(identifier => new AppTenantInfo(identifier, identifier));
The singleton store retains the factory and invokes it for each lookup. The factory must support concurrent calls.
Avoid capturing scoped services, such as a DbContext, or unsynchronized mutable state. Return a fully constructed
tenant whose Id and Identifier match the supplied identifier and remain stable.