Multi-Tenancy with Separate Databases Approach in .NET Core — A blog creation example

DotNet Full Stack Dev
4 min readApr 8, 2024

--

Multi-tenancy is an architectural pattern where a single instance of an application serves multiple tenants (clients or customers), while ensuring data isolation and security between them. In this blog, we’ll explore how to implement multi-tenancy using a separate databases approach in .NET Core.

Embark on a journey of continuous learning and exploration with DotNet-FullStack-Dev. Uncover more by visiting our https://dotnet-fullstack-dev.blogspot.com reach out for further information.

Understanding Multi-Tenancy

Multi-tenancy allows software as a service (SaaS) providers to efficiently manage and scale their applications by serving multiple tenants from a shared infrastructure. Each tenant has its own isolated set of resources, including databases, ensuring data privacy and security.

Separate Databases Approach

In the separate databases approach, each tenant is assigned its own dedicated database. This provides the highest level of data isolation, as tenant data is physically separated at the database level. Each tenant’s database contains tables, schemas, and data specific to that tenant.

Implementation Steps

1. Database Provisioning

  • Dynamic Database Creation: Implement logic to dynamically create databases for new tenants during onboarding.

2. Tenant Identification

  • Request Context: Identify the tenant based on request context, such as hostnames, subdomains, or request headers.

3. Database Connection Management

  • Connection Pooling: Use connection pooling to efficiently manage database connections for each tenant.
  • Database Context: Create a database context per tenant, allowing for separate database connections and transactions.

4. Data Access Layer

  • Repository Pattern: Implement a repository pattern to abstract data access logic and facilitate CRUD operations for each tenant’s database.

5. Configuration Management

  • Tenant Configuration: Store tenant-specific configurations, such as connection strings, in a central configuration store.

Code Snippets

Dynamic Database Creation

public void CreateTenantDatabase(string tenantId)
{
var dbName = $"{tenantId}_Database";
// Logic to create database
}

Tenant Identification Middleware

public void Configure(IApplicationBuilder app, ITenantResolver tenantResolver)
{
app.Use(async (context, next) =>
{
var tenantId = tenantResolver.Resolve(context.Request);
context.Items["TenantId"] = tenantId;
await next.Invoke();
});
}

Database Context per Tenant

public class TenantDbContext : DbContext
{
public TenantDbContext(DbContextOptions<TenantDbContext> options) : base(options)
{
}
// DbSet properties for tenant-specific entities
}

Repository Pattern

public interface IRepository<T>
{
Task<IEnumerable<T>> GetAllAsync(string tenantId);
Task<T> GetByIdAsync(string tenantId, int id);
Task AddAsync(string tenantId, T entity);
Task UpdateAsync(string tenantId, T entity);
Task DeleteAsync(string tenantId, int id);
}

Let’s consider a simple scenario where we’re building a multi-tenant blog platform. Each tenant (blog owner) will have their own dedicated database to manage their blog posts. Below are the implementation steps and code snippets for each:

Startup.cs:

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace MultiTenantBloggingPlatform
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}

public IConfiguration Configuration { get; }

public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();

// Register DbContext for Tenant 1
services.AddDbContext<Tenant1DbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("Tenant1Connection")));

// Register DbContext for Tenant 2
services.AddDbContext<Tenant2DbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("Tenant2Connection")));

// Register Tenant Resolver
services.AddSingleton<ITenantResolver, SubdomainTenantResolver>();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Blog}/{action=Index}/{id?}");
});
}
}
}

ITenantResolver.cs:

using Microsoft.AspNetCore.Http;

namespace MultiTenantBloggingPlatform
{
public interface ITenantResolver
{
string Resolve(HttpRequest request);
}
}

SubdomainTenantResolver.cs:

using Microsoft.AspNetCore.Http;

namespace MultiTenantBloggingPlatform
{
public class SubdomainTenantResolver : ITenantResolver
{
public string Resolve(HttpRequest request)
{
var subdomain = request.Host.Host.Split('.')[0];
// Logic to map subdomain to ownerId
return subdomain.Equals("tenant1") ? "tenant1" : "tenant2";
}
}
}

Tenant1DbContext.cs:

using Microsoft.EntityFrameworkCore;

namespace MultiTenantBloggingPlatform
{
public class Tenant1DbContext : DbContext
{
public Tenant1DbContext(DbContextOptions<Tenant1DbContext> options) : base(options)
{
}

public DbSet<Post> Posts { get; set; }
}
}

Tenant2DbContext.cs:

using Microsoft.EntityFrameworkCore;

namespace MultiTenantBloggingPlatform
{
public class Tenant2DbContext : DbContext
{
public Tenant2DbContext(DbContextOptions<Tenant2DbContext> options) : base(options)
{
}

public DbSet<Post> Posts { get; set; }
}
}

BlogController.cs:

using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Linq;

namespace MultiTenantBloggingPlatform
{
public class BlogController : Controller
{
private readonly Tenant1DbContext _tenant1DbContext;
private readonly Tenant2DbContext _tenant2DbContext;
private readonly ITenantResolver _tenantResolver;

public BlogController(Tenant1DbContext tenant1DbContext, Tenant2DbContext tenant2DbContext, ITenantResolver tenantResolver)
{
_tenant1DbContext = tenant1DbContext;
_tenant2DbContext = tenant2DbContext;
_tenantResolver = tenantResolver;
}

public IActionResult Index()
{
var tenantId = GetCurrentTenantId(); // Get current tenant identifier
var posts = tenantId switch
{
"tenant1" => _tenant1DbContext.Posts.ToList(), // Get posts for Tenant 1
"tenant2" => _tenant2DbContext.Posts.ToList(), // Get posts for Tenant 2
_ => new List<Post>()
};

return View(posts);
}

private string GetCurrentTenantId()
{
var tenantId = _tenantResolver.Resolve(Request); // Resolve tenant identifier from request
return tenantId;
}
}
}

Post.cs:

namespace MultiTenantBloggingPlatform
{
public class Post
{
public int Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public string OwnerId { get; set; } // Tenant Identifier
}
}

appsettings.json:

{
"ConnectionStrings": {
"Tenant1Connection": "YourTenant1ConnectionString",
"Tenant2Connection": "YourTenant2ConnectionString"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*"
}

Ensure to replace "YourTenant1ConnectionString" and "YourTenant2ConnectionString" with the actual connection strings for the databases of Tenant 1 and Tenant 2, respectively.

In this expanded example:

  • We’ve added comments for better understanding.
  • Created separate DbContext classes (Tenant1DbContext and Tenant2DbContext) for each tenant, configuring them to use their respective connection strings.
  • Implemented a GetCurrentTenantId method in the controller to simulate retrieving the current tenant identifier from the request context. In a real application, you would implement logic to properly identify the current tenant.
  • Modified the Index action in the BlogController to retrieve blog posts based on the current tenant identifier.
  • We’ve added a constructor parameter of type ITenantResolver to inject the tenant resolver implementation into the BlogController.

With this setup, you have a complete .NET Core application implementing a multi-tenant blogging platform with separate databases for each tenant. Adjust the code and connection strings according to your specific requirements and database configurations.

Conclusion

The separate databases approach offers strong data isolation and security for multi-tenant applications. By following the steps outlined above and leveraging .NET Core features such as middleware, dependency injection, and EF Core, you can effectively implement multi-tenancy with separate databases in your .NET Core applications.

By following these steps and utilizing the provided code snippets, you can implement multi-tenancy with separate databases in your .NET Core application for a blog platform or similar scenario. Adjustments may be needed based on your specific requirements and infrastructure setup.

--

--

DotNet Full Stack Dev
DotNet Full Stack Dev

Written by DotNet Full Stack Dev

Join me to master .NET Full Stack Development & boost your skills by 1% daily with insights, examples, and techniques! https://dotnet-fullstack-dev.blogspot.com

No responses yet