Implementing multi-tenancy in ASP.Net: Resolving the tenant
In the first article in this series, we explored the need for multitenancy and the different forms thereof. The second blog post explored setting up of the database. In this article, we will look at resolving the tenant on the fly so that we can connect to the connect database. You can find the code for this article on this repo.
Here are all the articles
- Implementing multi-tenancy in ASP.Net
- Implementing multi-tenancy in ASP.Net: Setting up Database Contexts
- Implementing multi-tenancy in ASP.Net: Resolving the tenant
- Implementing multi-tenancy in ASP.Net: Middleware
Let’s dive right in.
Running migrations for the Tenant Database
At this time, we have not yet added the Listings table to the Tenant database. Therefore we need to see how to run migrations during design time.
The challenge
You may be asking yourself what the problem is since we can just configure the connection string on the OnConfiguring(). Not so straight-forward. Lets look at the current TenantDbContext to get a better understanding of the problem.
public class TenantDbContext : DbContext
{
public DbSet<Listing> Listings { get; set; }
public TenantDbContext(DbContextOptions<TenantDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(Listing).Assembly);
}
}
At design time, we need a default constructor for us to run migrations. This is okay for now. However, we need to be able to pass the connection string to the database constructor during run time. That means we will have two constructors, and no “default” constructor.
public class TenantDbContext : DbContext
{
private readonly string? _connectionString;
public DbSet<Listing> Listings { get; set; }
public TenantDbContext(DbContextOptions<TenantDbContext> options) : base(options) { }
public TenantDbContext(string connectionString) : base(CreateOptions(connectionString))
{
_connectionString = connectionString;
}
private static DbContextOptions CreateOptions(string connectionString)
{
var optionsBuilder = new DbContextOptionsBuilder<TenantDbContext>();
optionsBuilder.UseSqlServer(connectionString);
return optionsBuilder.Options;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (!optionsBuilder.IsConfigured)
{
optionsBuilder.UseSqlServer(_connectionString);
}
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(Listing).Assembly);
}
}
In the code above we have added a second constructor. Take note of
: base(CreateOptions(connectionString))
in that constructor.
Because we cannot configure the connection string at design-time, if we try to add a migration using:
Update-Database -Context TenantDbContext
…we get an error:
The ConnectionString property has not been initialized.
The solution
We need a design-time factory which implements IDesignTimeDbContextFactory
. The IDesignTimeDbContextFactory
, is a mechanism provided by Entity Framework Core to create DbContext
instances when you're running migrations and other EF Core command-line tools. This factory is necessary in several scenarios:
- No Default Constructor: When your
DbContext
doesn't have a default constructor, which is often the case in real-world applications where you're injecting dependencies such asDbContextOptions
through the constructor. - Separate Configuration for Development: It allows you to provide a specific configuration for the
DbContext
that may be different from the one used at runtime. For instance, you might connect to a different database when developing or running migrations than the one used in production. - Web Applications and Services: In web applications, the
DbContext
is typically configured at runtime based on the request and application settings. Since the EF Core tools are not running within the application's runtime environment, they can't access the service provider or the configuration settings. The design-time factory serves as a bridge to provide the requiredDbContext
to the tools. - Multi-Tenant Applications: In a multi-tenant system (like ours), the connection string might be determined at runtime based on the tenant making the request. The design-time factory allows you to bypass the runtime logic and provide a consistent connection string for migrations.
- Complex Initialization: If your
DbContext
initialization involves complex logic or setup that is not required for, or should be different for, design-time operations, the factory gives you a place to encapsulate this complexity.
Creating the factory
For migrations, use a design-time DbContext factory. This factory can be added to [project]/Data/Factories folder:
public class TenantDesignTimeDbContextFactory : IDesignTimeDbContextFactory<TenantDbContext>
{
public TenantDbContext CreateDbContext(string[] args)
{
// Build configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
// Get connection string
var connectionString = configuration["MyAppSettings:DesignTimeConnectionString"];
var optionsBuilder = new DbContextOptionsBuilder<TenantDbContext>();
optionsBuilder.UseSqlServer(connectionString);
return new TenantDbContext(optionsBuilder.Options);
}
}
We also need to provide the connection string in the appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"MyAppSettings": {
"PlatformConnectionString": "Server =.\\SQLExpress;Database=Platform;Trusted_Connection=True;TrustServerCertificate=True;",
"DesignTimeConnectionString": "Server =.\\SQLExpress;Database=Tenant;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
Now, when we run:
Add-Migration InitialMigration -Context TenantDbContext -OutputDir Migrations/Tenant
…we actually generate a migration which we can update the database with:
public partial class InitialMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Listings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Slug = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
Price = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
DateCreated = table.Column<DateTime>(type: "datetime2", nullable: false),
DateUpdated = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Listings", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Listings");
}
}
Now that we have the Tenant database set-up, let’s move on to determining which tenant is accessing our API.
Resolving the tenant
To get the connection string of the tenant, we need two things:
A tenant resolver
There are several strategies we have for resolving tenants:
- Subdomain: As in the
SubdomainTenantResolver
example, the tenant is determined based on the subdomain of the request URL. Each tenant is assigned a unique subdomain (tenant1.example.com, tenant2.example.com). This is the one we will be using in our example. - Domain: Similar to subdomain but uses the full domain to resolve the tenant. This is used when each tenant has its own domain (www.tenant1.com, www.tenant2.com).
- Path: The tenant is resolved based on a specific path within the URL (example.com/tenant1, example.com/tenant2).
- Query String: The tenant identifier is passed as a query string parameter in the URL (example.com?tenant=tenant1).
- Headers: Tenant information can be stored in HTTP headers, especially when using APIs.
- Cookie: Tenants can be resolved by reading a cookie that’s been set with the tenant identifier upon login or as part of session management.
- JWT Token: If using JWT tokens for authentication, the tenant identifier can be included as a claim within the token.
- IP Address: The tenant could be resolved by the IP address, especially in B2B scenarios where each tenant may have a known IP range.
- Custom Logic in Middleware: Any custom logic that looks at various aspects of the request and user session. This could be a combination of methods, like checking a user’s profile data fetched by an auth token.
- Tenant-Specific Ports: Different tenants could access the application through different ports, which are mapped to specific tenants.
- User Profile or Session: Post-authentication, the tenant ID could be stored in the user’s profile or session state.
Since we have several options, we need to create an interface for resolving tenants.
public interface ITenantResolver
{
Task<Tenant> ResolveAsync();
}
Notice that this resolver does not pay attention to which method (from above) we will be using. The implementation details will be in the concrete implementation of the interface.
public class SubdomainTenantResolver : ITenantResolver
{
private readonly PlatformDbContext _db;
private readonly IHttpContextAccessor _httpContextAccessor;
public SubdomainTenantResolver(PlatformDbContext db, IHttpContextAccessor httpContextAccessor)
{
_db = db;
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// Resolves the tenant from the sub-domain
/// </summary>
/// <returns>An instance of the Tenant</returns>
/// <exception cref="Exception">Throw if the sub-domain does not resolve to a Tenant</exception>
public async Task<Tenant> ResolveAsync()
{
var host = _httpContextAccessor.HttpContext.Request.Host.Host;
if (host == "localhost")
{
return await _db.Tenants.FirstOrDefaultAsync(t => t.Code == host) ?? throw new Exception($"Tenant not found for host '{host}'");
}
var subdomain = GetSubdomain(host);
var tenant = await _db.Tenants.FirstOrDefaultAsync(t => t.Code == subdomain);
return tenant ?? throw new Exception($"Tenant not found for subdomain '{subdomain}'");
}
private string GetSubdomain(string host)
{
var parts = host.Split('.');
if (parts.Length == 3)
{
return parts[0];
}
else
{
return string.Empty;
}
}
}
Here’s a breakdown of the SubdomainTenantResolver
class, which is a concrete implementation of the ITenantResolver
interface:
Constructor: The SubdomainTenantResolver
constructor injects two dependencies:
PlatformDatabaseContext _db
: This is our platform-level DbContext that contains the tenant information.IHttpContextAccessor _httpContextAccessor
: This service allows us to access theHttpContext
, which is the encapsulation of all HTTP-specific information about an individual HTTP request.
ResolveAsync Method: The ResolveAsync
method is the heart of this resolver. It performs the following steps:
- It retrieves the host part of the current HTTP request via
_httpContextAccessor.HttpContext.Request.Host.Host
. - If the host is
localhost
, which we are using for development, it attempts to return a tenant whoseCode
matches "localhost". This is a special handling case for local testing. - If the host is not
localhost
, it parses the host string to extract the subdomain. This is accomplished by theGetSubdomain
private method. - Once the subdomain is extracted, it queries the platform database’s
Tenants
table for a tenant with a matchingCode
. - If a matching tenant is found, it is returned; otherwise, an
Exception
is thrown, indicating that no tenant corresponds to the subdomain.
GetSubdomain Method: The GetSubdomain
method splits the host string into parts based on the '.'
character. In a typical scenario where a host is formatted as [subdomain].[domain].[TLD]
, the method returns the first part as the subdomain. If the host does not conform to this format, it returns an empty string, implying no subdomain is present.
Usage in Middleware: The SubdomainTenantResolver
will be injected into our TenantResolutionMiddleware
. The middleware uses it to resolve the tenant for each incoming request, ensuring that the subsequent layers of the application interact with the correct tenant database.
Don’t forget to register the interface for DI. Start by extending the ConfigurationServiceCollectionExtensions
.
public static class ConfigurationServiceCollectionExtensions
{
public static IServiceCollection AddMyOptions(this IServiceCollection services)
{
services.AddOptions<MyAppOptions>().BindConfiguration("MyAppSettings");
return services;
}
public static IServiceCollection AddReolvers(this IServiceCollection services)
{
services.AddScoped<ITenantResolver, SubdomainTenantResolver>();
return services;
}
}
…and in Program.cs:
using MultiTenancyDemo.Data;
using MultiTenancyDemo.Extensions;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddMyOptions();
builder.Services.AddReolvers();
builder.Services.AddDbContext<PlatformDbContext>();
builder.Services.AddDbContext<TenantDbContext>();
In the next article, we will see how to use this resolver to get the tenant’s connection string, and how to use the TenantDbContext to get the Listings.