Finbuckle.
Data isolation is one of the most important considerations in a multi-tenant app. Whether each tenant has its own database, a shared database, or a hybrid approach can make a significant different in app design. Finbuckle.MultiTenant supports each of these models by associating a connection string with each tenant.
If each tenant uses a separate database then add a ConnectionString
property to the app's ITenantInfo
implementation. and use it in the OnConfiguring
method of the database context class. The tenant info can be obtained
by injecting a IMultiTenantContextAccessor<TTenantInfo>
into the database context class constructor.
public class AppTenantInfo : ITenantInfo
{
public string Id { get; set; }
public string Identifier { get; set; }
public string Name { get; set; }
public string ConnectionString { get; set; }
}
public class MyAppDbContext : DbContext
{
// AppTenantInfo is the app's custom implementation of ITenantInfo which
private AppTenantInfo TenantInfo { get; set; }
public MyAppDbContext(IMultiTenantContextAccessor<AppTenantInfo> multiTenantContextAccessor)
{
// get the current tenant info at the time of construction
TenantInfo = multiTenantContextAccessor.tenantInfo;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
// use the connection string to connect to the per-tenant database
optionsBuilder.UseSqlServer(TenantInfo.ConnectionString);
}
...
}
This approach does not require the added complexity described below for a shared database approach, but does come with its own complexity in operating and maintaining a larger number of database instances and infrastructure.
In shared database scenarios it is important to make sure that queries and commands for a tenant do not affect the data
belonging to other tenants. Finbuckle.MultiTenant handles this automatically and removes the need to sprinkle "where"
clauses all over an app. Internally a shadow TenantId
property is added (or used if already present) to multi-tenant
entity types and managed as the database context is used. It also performs validation and related options for handling
null or mismatched tenants.
Finbuckle.MultiTenant provides two different ways to utilize this behavior in a database context class:
IMultiTenantDbContext
and used the helper methods as
described below, orMultiTenantDbContext
which handles the details for you.The first option is more complex, but provides enhanced flexibility and allows existing database context classes (which may derive from a base class) to utilize per-tenant data isolation. The second option is easier, but provides less flexibility. These approaches are both explained further below.
Regardless of how the database context is configured, the context will need to know which entity types should be treated
as multi-tenant (i.e. which entity types are to be isolated per tenant) When the database context is initialized, a
shadow property named TenantId
is added to the data model for designated entity types. This property is used
internally to filter all requests and commands. If there already is a defined string property named TenantId
then it
will be used.
There are two ways to designate an entity type as multi-tenant:
[MultiTenant]
data attributeIsMultiTenant
Entity types not designated via one of these methods are not isolated per-tenant all instances are shared across all tenants.
[MultiTenant]
attributeThe [MultiTenant]
attribute designates a class to be isolated per-tenant when it is used as an entity type in a
database context:
// tenants will only see their own blog posts
[MultiTenant]
public class BlogPost
{
...
}
// roles will be the same for all tenants
public class Roles
{
...
}
public class BloggingDbContext : MultiTenantDbContext
{
public BloggingDbContext(IMultiTenantContextAccessor multiTenantContextAccessor) : base(multiTenantContextAccessor)
{
}
public DbSet<BlogPost> BlogPosts { get; set; } // this will be multi-tenant!
public DbSet<Roles> Roles { get; set; } // not multi-tenant!
}
Database context classes derived from MultiTenantDbContext
will automatically respect the [MultiTenant]
attribute.
Otherwise, a database context class can be configured to respect the attribute by calling ConfigureMultiTenant
in the
OnModelCreating
method.
protected override void OnModelCreating(ModelBuilder builder)
{
// not needed if database context derives from MultiTenantDbContext
builder.ConfigureMultiTenant();
}
The fluent API entity type builder extension method IsMultiTenant
can be called in OnModelCreating
to provide the
multi-tenant functionality for entity types:
protected override void OnModelCreating(ModelBuilder builder)
{
// Configure an entity type to be multi-tenant.
builder.Entity<MyEntityType>().IsMultiTenant();
}
This approach is more flexible than using the [MultiTenant]
attribute because it can be used for types which do not
have the attribute, e.g. from another assembly.
IsMultiTenant()
returns an MultiTenantEntityTypeBuilder
instance which enables further multi-tenant configuration of
the entity type via AdjustKey
,AdjustIndex
, AdjustIndexes
, and AdjustUniqueIndexes
. See Keys and Indexes for
more details.
IsMultiTenant
and the [MultiTenant]
attribute use a query filter for data isolation and will automatically merge its
query filter with an existing query filter is one is present. For that reason, if the type to be multi-tenant has an
existing query filter, IsMultiTenant
and ConfigureMultiTenant
should be called after the existing query filter is
configured:
protected override void OnModelCreating(ModelBuilder builder)
{
// set a global query filter, e.g. to support soft delete
builder.Entity<MyEntityType>().HasQueryFilter(p => !p.IsDeleted);
// configure an entity type to be multi-tenant (will merge with existing call to HasQueryFilter)
builder.Entity<MyEntityType>().IsMultiTenant();
}
This approach is more flexible than deriving from MultiTenantDbContext
, but needs more configuration. It requires
implementing IMultiTenantDbContext
and following a strict convention of helper method calls.
Start by adding the Finbuckle.MultiTenant.EntityFrameworkCore
package to the project:
dotnet add package Finbuckle.MultiTenant.EntityFrameworkCore
Next, implement IMultiTenantDbContext
on the context. These interface properties ensure that the extension methods
will have the information needed to provide proper data isolation.
public class MyDbContext : DbContext, IMultiTenantDbContext
{
...
public ITenantInfo TenantInfo { get; }
public TenantMismatchMode TenantMismatchMode { get; }
public TenantNotSetMode TenantNotSetMode { get; }
...
}
The database context will need to ensure that these properties haves values, either through constructors, setters, or default values.
In earlier version of Finbuckle.MultiTenant
ITenantInfo
and the app implementation where available via dependency injection, but this was removed in v7.0.0 for consistency. Instead, inject theIMultiTenantContextAccessor
and use it to set theTenantInfo
property in the database context constructor.
Finally, call the library extension methods as described below. This requires overriding
the OnModelCreating
, SaveChanges
, and SaveChangesAsync
methods.
In OnModelCreating
use the EntityTypeBuilder
fluent API extension method IsMultiTenant
to designate entity types
as multi-tenant. Call ConfigureMultiTenant
on the ModelBuilder
to configure each entity type marked with
the [MultiTenant]
data attribute. This is only needed if using the attribute and internally uses the IsMultiTenant
fluent API. Make sure to call the base class OnModelCreating
method if necessary, such as if inheriting
from IdentityDbContext
.
protected override void OnModelCreating(ModelBuilder builder)
{
// If necessary call the base class method.
// Recommended to be called first.
base.OnModelCreating(builder);
// Configure all entity types marked with the [MultiTenant] data attribute
builder.ConfigureMultiTenant();
// Configure an entity type to be multi-tenant.
builder.Entity<MyEntityType>().IsMultiTenant();
}
In SaveChanges
and SaveChangesAsync
call the IMultiTenantDbContext
extension method EnforceMultiTenant
before
calling the base class method. This ensures proper data isolation and behavior for TenantMismatchMode
and TenantNotSetMode
.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
this.EnforceMultiTenant();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default(CancellationToken))
{
this.EnforceMultiTenant();
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
Now, whenever this database context is used it will only set and query records for the current tenant.
MultiTenantDbContext
This approach is easier bit requires inheriting from MultiTenantDbContext
which may not always be possible. It is
simply a pre-configured implementation of IMultiTenantDbContext
with the helper methods as described above in
Adding MultiTenant Functionality to an Existing DbContext
Start by adding the Finbuckle.MultiTenant.EntityFrameworkCore
package to the project:
dotnet add package Finbuckle.MultiTenant.EntityFrameworkCore
The MultiTenantDbContext
has two constructors which should be called from any derived database context. Make sure to
forward the IMultiTenatContextAccessor
and, if applicable the DbContextOptions<T>
into the base constructor.
Variants of these constructors that pass ITenantInfo
to the base constructor are also available, but these will not be
used for dependency injection.
public class BloggingDbContext : MultiTenantDbContext
{
// these constructors are called when dependency injection is used
public BloggingDbContext(IMultiTenantContextAccessor multiTenantContextAccessor) : base(multiTenantContextAccessor)
{
}
public BloggingDbContext(IMultiTenantContextAccessor multiTenantContextAccessor, DbContextOptions<BloggingDbContext> options) :
base(multiTenantContextAccessor, options)
{
}
// these constructors are useful for testing or other use cases where depdenency injection is not used
public BloggingDbContext(ITenantInfo tenantInfo) : base(tenantInfo) { }
public BloggingDbContext(ITenantInfo tenantInfo, DbContextOptions<BloggingDbContext> options) :
base(tenantInfo, options) { }
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
}
If the derived database context overrides OnModelCreating
is it recommended that the base class OnModelCreating
method is called last so that the multi-tenant query filters are not overwritten.
public class BloggingDbContext : MultiTenantDbContext
{
...
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// set a global query filter, e.g. to support soft delete
modelBuilder.Entity<Post>().HasQueryFilter(p => !p.IsDeleted);
// call the base library implementation AFTER the above
base.OnModelCreating(modelBuilder);
}
...
}
Now, whenever this database context is used it will only set and query records for the current tenant.
When using a shared database context based on IMultiTenantDbContext
it is simple extend into a hybrid approach simply
by assigning some tenants to a separate shared database (or its own completely isolated database) via the tenant info
connection string property.
Given that a multi-tenant database context usually requires a tenant to function, design time instantiation can be
challenging. By default, for things like migrations and command line tools Entity Framework core attempts to create an
instance of the context using dependency injection, however usually no valid tenant exists in these cases and DI fails.
For this reason it is recommended to use a design time factory wherein a dummy ITenantInfo
is
constructed with the desired connection string and passed to the database context constructor.
When registering the database context as a service in ASP.NET Core it is important to take into account whether the
connection string and/or provider will vary per-tenant. If so, it is recommended to set the connection string and
provider in the OnConfiguring
database context method as described above rather than in the AddDbContext
service
registration method.
Added entities are automatically associated with the current TenantInfo
. If an entity is associated with a
different TenantInfo
then a MultiTenantException
is thrown in SaveChanges
or SaveChangesAsync
.
// Add a blog for a tenant.
Blog myBlog = new Blog{ Title = "My Blog" };;
var db = new BloggingDbContext(myTenantInfo, null);
db.Blogs.Add(myBlog));
db.SaveChanges();
// Try to add the same blog to a different tenant.
db = new BloggingDbContext(yourTenantInfo, null);
db.Blogs.Add(myBlog);
await db.SaveChangesAsync(); // Throws MultiTenantException.
Queries only return results associated to the TenantInfo
.
// Will only return "My Blog".
var db = new BloggingDbContext(myTenantInfo, null);
var tenantBlog = db.Blogs.First();
// Will only return "Your Blog".
db = new BloggingDbContext(yourTenantInfo, null);
var tenantBlogs = db.Blogs.First();
IgnoreQueryFilters
can be used to bypass the filter for LINQ queries.
// TenantBlogs will contain all blogs, regardless of tenant.
var db = new BloggingDbContext(myTenantInfo, null);
var tenantBlogs = db.Blogs.IgnoreQueryFilters().ToList();
The query filter is applied only at the root level of a query. Any entity classes loaded via Include
or ThenInclude
are not filtered, but if all entity classes involved in a query have the [MultiTenant]
attribute then all results are
associated to the same tenant.
Updated or deleted entities are checked to make sure they are associated with the TenantInfo
. If an entity is
associated with a different TenantInfo
then a MultiTenantException
is thrown in SaveChanges
or SaveChangesAsync
.
// Add a blog for a tenant.
Blog myBlog = new Blog{ Title = "My Blog" };
var db = new BloggingDbContext(myTenantInfo);
db.Blogs.Add(myBlog));
db.SaveChanges();
// Modify and attach the same blog to a different tenant.
db = new BloggingDbContext(yourTenantInfo, null);
db.Blogs.Attach(myBlog);
myBlog.Title = "My Changed Blog";
await db.SaveChangesAsync(); // Throws MultiTenantException.
db.Blogs.Remove(myBlog);
await db.SaveChangesAsync(); // Throws MultiTenantException.
When configuring a multi-tenant entity type it is often useful to include the implicit TenantId
column in the primary
key and/or indexes. The MultiTenantEntityTypeBuilder
instance returned from IsMultiTenant()
provides the following
methods for this purpose:
AdjustKey(IMutableKey, ModelBuilder)
- Alters the existing defined key to add the implicit TenantId
. Note that
this will also impact entities with a dependent foreign key and may add an implicit Tenant Id
there as well.AdjustIndex(IMutableIndex)
- Alters an existing index include the implicit TenantId
.AdjustIndexes()
- Alters all existing indexes to include the implicit TenantId
.AdjustUniqueIndexes()
- Alters only all existing unique indexes to include te implicit TenantId
.protected override void OnModelCreating(ModelBuilder builder)
{
// Configure an entity type to be multi-tenant, adjust the existing keys and indexes
var key = builder.Entity<Blog>().Metadata.GetKeys().First();
builder.Entity<MyEntityType>().IsMultiTenant().AdjustKey(key, builder).AdjustIndexes();
}
Normally Finbuckle.MultiTenant will automatically coordinate the TenantId
property of each entity. However, in certain
situations the TenantId
can be manually set.
By default, attempting to add or update an entity with a different TenantId
property throws a MultiTenantException
during a call to SaveChanges
or SaveChangesAsync
. This behavior can be changed by setting the TenantMismatchMode
property on the database context:
TenantMismatchMode.Throw
- A MultiTenantException
is thrown (default).TenantMismatchMode.Ignore
- The entity is added or updated without modifying its TenantId
.TenantMismatchMode.Overwrite
- The entity's TenantId
is overwritten to match the database context's
current TenantInfo
.If the TenantId
on an entity is manually set to null the default behavior is to overwrite the TenantId
for added
entities or to throw a MultiTenantException
for updated entities. This occurs during a call to SaveChanges
or SaveChangesAsync
. This behavior can be changed by setting the TenantNotSetMode
property on the database context:
TenantNotSetMode.Throw
- For added entities the null TenantId
will be overwritten to match the database context's
current TenantInfo
. For updated entities a MultiTenantException
is thrown (default).TenantNotSetMode.Overwrite
- The entity's TenantId
is overwritten to match the database context's
current TenantInfo
.