Entity Framework Multitenancy Part 2 – Conventions

In my last post, I talked about different scenarios for achieving multitenancy with Entity Framework contexts. This time, I am going to show how to use conventions, wrapping the code I provided then.

First, a base convention class to serve as our root hierarchy of multitenant conventions:

public abstract class MultitenantConvention : IConvention
{
}

Only worthy of mention is the implementation of IConvention. This is a marker interface for letting Entity Framework know that this is a convention.

Next, a convention for the separate databases approach:

public class SeparateDatabasesConvention : MultitenantConvention
{
    public SeparateDatabasesConvention(DbContext ctx)
    {
        var currentTenantId = TenantConfiguration.GetCurrentTenant();
        ctx.Database.Connection.ConnectionString = ConfigurationManager.ConnectionStrings[currentTenantId].ConnectionString;
    }
}

This convention needs a reference to the DbContext, because it needs to change the connection string dynamically, something that you can’t do through the basic convention interfaces and classes – there’s no way to get to the context.

Now, shared database, different schemas. This time, we need to implement IStoreModelConvention<T>, using EntitySet as the generic parameter, so as to gain access to the Schema property:

public class SharedDatabaseSeparateSchemaConvention : MultitenantConvention, IStoreModelConvention<EntitySet>
{
    public void Apply(EntitySet item, DbModel model)
    {
        var currentTenantId = TenantConfiguration.GetCurrentTenant();
        item.Schema = currentTenantId;
    }
}

Finally, shared database, shared schema:

public class SharedDatabaseSharedSchemaConvention : MultitenantConvention
{
    public String DiscriminatorColumnName { get; private set; }
 
    private void Map<T>(EntityMappingConfiguration<T> cfg) where T : class
    {
        var currentTenantId = TenantConfiguration.GetCurrentTenant();
        cfg.Requires(this.DiscriminatorColumnName).HasValue(currentTenantId);
    }
 
    public SharedDatabaseSharedSchemaConvention(DbModelBuilder modelBuilder, String discriminatorColumnName = "Tenant")
    {
        this.DiscriminatorColumnName = discriminatorColumnName;
 
        var modelConfiguration = modelBuilder.GetType().GetProperty("ModelConfiguration", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(modelBuilder, null);
        var entities = modelConfiguration.GetType().GetProperty("Entities", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(modelConfiguration, null) as IEnumerable<Type>;
 
        foreach (var entity in entities)
        {
            var entityTypeConfiguration = modelBuilder.GetType().GetMethod("Entity").MakeGenericMethod(entity).Invoke(modelBuilder, null);
            var mapMethod = entityTypeConfiguration.GetType().GetMethods().First(m => m.Name == "Map");
 
            var localMethod = this.GetType().GetMethod("Map", BindingFlags.Instance | BindingFlags.NonPublic).MakeGenericMethod(entity);
            var delegateType = typeof(Action<>).MakeGenericType(localMethod.GetParameters().First().ParameterType);
 
            var del = Delegate.CreateDelegate(delegateType, this, localMethod);
 
            mapMethod.Invoke(entityTypeConfiguration, new Object[] { del });
        }
    }
}

And a nice way to wrap all this using extension methods:

public static class DbModelBuilderExtensions
{
    public static DbModelBuilder UseSeparateDatabases(this DbModelBuilder modelBuilder, DbContext ctx)
    {
        modelBuilder.Conventions.Remove<MultitenantConvention>();
        modelBuilder.Conventions.Add(new SeparateDatabasesConvention(ctx));
        return modelBuilder;
    }
    public static DbModelBuilder UseSharedDatabaseSeparateSchema(this DbModelBuilder modelBuilder)
    {
        modelBuilder.Conventions.Remove<MultitenantConvention>();
        modelBuilder.Conventions.Add(new SharedDatabaseSeparateSchemaConvention());
        return modelBuilder;
    }
    public static DbModelBuilder UseSharedDatabaseSharedSchema(this DbModelBuilder modelBuilder, String discriminatorColumnName = "Tenant")
    {
        modelBuilder.Conventions.Remove<MultitenantConvention>();
        modelBuilder.Conventions.Add(new SharedDatabaseSharedSchemaConvention(modelBuilder, discriminatorColumnName));
        return modelBuilder;
    }
}

Usage: just uncomment one of the Use calls.

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
    //uncomment one of the following lines
    //modelBuilder.UseSeparateDatabases(this);
    //modelBuilder.UseSharedDatabaseSeparateSchema();
    //modelBuilder.UseSharedDatabaseSharedSchema(discriminatorColumnName: "Tenant");
 
    base.OnModelCreating(modelBuilder);
}

                             

4 Comments

  • I have a question.
    Do I need to set the default schema?
    If I do not set this I get an exception telling that the model changed.

    In the scenario Shared database, separate schemas I had to add the default schema to the DbModelBuilder.
    protected override void OnModelCreating(DbModelBuilder modelBuilder)
    {
    modelBuilder.HasDefaultSchema TenantConfiguration.GetCurrentTenantId());
    modelBuilder.UseSharedDatabaseSeparateSchema();
    base.OnModelCreating(modelBuilder);
    }

    Is this correct?

  • Wessel:
    The way I understand it is: dbo is the default global schema, but individual users can have their own default schemas. So it is always recommended that you do specify it!

  • Thanks Ricardo. Your post really helped me.

    I am using the shared database, shared schema approach but had some classes that I didn't want to be multitenancy-enabled (reference data, etc.). Maybe my solution is useful for someone else as well.

    First, I created a data annotation attribute:

    namespace System.ComponentModel.DataAnnotations
    {
    /// <summary>
    /// Enables Multi-Tenancy
    /// </summary>
    [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
    public class EnableMultiTenancyAttribute : Attribute
    {
    public EnableMultiTenancyAttribute(bool enableMultiTenancy = true)
    {
    Enabled = enableMultiTenancy;
    }

    public bool Enabled { get; set; }
    }
    }

    And the I adjusted the SharedDatabaseSharedSchemaConvention(...)-method by adding a Where-clause to the foreach-loop:

    foreach (var entity in entities.Where(p => (p.GetCustomAttributes(true).OfType<EnableMultiTenancyAttribute>().SingleOrDefault() != null && p.GetCustomAttributes(true).OfType<EnableMultiTenancyAttribute>().SingleOrDefault().Enabled)))

    I'd guess that similar behavior for the other two approaches can be implemented easily.

  • Thanks, Adrian!

Add a Comment

As it will appear on the website

Not displayed

Your website