Introducción
Continuando con la última parte de esta serie de posts en asp.net core, hoy veremos como seguir el approach Multi-Tenant con Multi-Database (Tenant Per Database).
De igual forma, exploraremos las ventajas y desventajas de utilizar múltiples bases de datos para múltiples tenants y veremos que opción es mejor según el escenario.
Te vuelvo a recordar que esta serie de posts viene con muchos code snippets, es mejor que lo leas siguiendo el repositorio en Github con el ejemplo final.
Esta serie de posts se dividen en 3 partes:
- ASP.NET Core 6: Creando una app Multi-tenant (Parte 1)
- ASP.NET Core 6: Multi-tenant Single Database (Parte 2)
- ASP.NET Core 6: Multi-tenant Multi-Database (Parte 3) (este post)
Tipos de multi-tenancy con multi-database
Ahora toca ver las modalidades de hacer multi-tenancy con varias bases de datos. Este puede ser o será el approach más usado en la mayoría de los casos, pero como en todo, siempre hay que ver sus ventajas y desventajas y verificar si es lo mejor para la solución que estemos creando.
Database Per Tenant
En esta modalidad, cada Tenant cuenta con su propia base de datos. Este approach lo consideraría como el mejor y el más usado. veamos por qué.
Seguridad
- ✔️ Alto nivel de aislamiento, permite distribuir las bases de datos en distintos servidores.
- ❌ Al contar con más servidores y más bases de datos, también hay que administrarlos y mantenerlos seguros.
Mantenibilidad
- ✔️ El mantenimiento se lleva acabo por tenant y puede ser personalizado según la carga de la BD
- ✔️ Fácilmente se puede restaurar/reubicar/limpiar la información de cada tenant
- ✔️ No hay complejidad en los Queries
- ❌ Agregar Tenants nuevos requiere de más trabajo, por ser una base de datos totalmente aparte
- Solución: Automatizar el proceso de creación de tenants
- ❌ Con forme vayan creciendo los tenants, existirán muchas bases de datos que mantener y administrar
Escalabilidad
- ✔️ Scale-out y Scale-up son opciones viables — Los tenants pueden ser distribuidos en múltiples servidores
- ✔️ Elegir un balance entre costo (alta densidad / menos servidores) y eficiencia (baja densidad / más servidores)
- ✔️ El efecto "noisy neighbor" no nos afecta (tanto).
Multiple Databases, Multiple Tenants (Shared Schema)
Esta modalidad es un hibrido entre Table Based del post pasado y Database Per Tenant. Existen múltiples bases de datos y dentro de cada base de datos puede existir 1 o más tenants.
Seguridad
- ✔️ Existe un aislamiento parcial al utilizar múltiples bases de datos
- ❌ Hay tenants que siguen compartiendo el esquema
- De igual forma el RLS funciona como mitigación
Mantenibilidad
- ✔️ La opción de elegir si queremos tener muchas bases de datos (densidad de tenants baja) o pocas bases de datos (densidad de tenants alta)
- ✔️ Posibilidad de reubicar la información de un tenant (aunque más difícil que el approach tenant per database)
- ❌ Genera más mantenimiento que si usaramos el puro approach Table based
Escalabilidad
- ✔️ Scale-out y Scale-up son opciones viables — Los tenants pueden ser distribuidos en múltiples servidores
- ✔️ Elegir un balance entre costo (alta densidad / menos servidores) y eficiencia (baja densidad / más servidores)
¿ Por qué Database Per Tenant?
Así como en el post anterior comenté porque he usado el modo table-based explicaré porque también uso un tenant por base de datos.
La configuración desde ASP.NET Core para la selección de que base de datos usar es relativamente sencilla y esta modalidad la utilizo para aplicaciones Monoliticas. En las que estoy seguro que tendremos muchas tablas y el crecimiento de tenants puede ser indefinido.
El aislamiento es la mejor parte, porque sabemos que cada cliente tiene su información totalmente separada que la de otros clientes. Restaurar la información de un cliente sin afectar a otros es buena razón para irnos por este approach.
El mantenimiento será brutal, pero con tanta información, siempre será una tarea de dedicación y cuidado (sin importar que esquema multitenant usemos).
SingleTenant DbContext
Para continuar con este ejemplo, estoy haciéndolo en un proyecto aparte (Que encontrarás en el repositorio de GitHub) y me estoy basando totalmente en el contenido del Post 1.
Necesitaremos ahora, un contexto llamado SingleTenantDbContext
(muy similar al del Post 2)
public class SingleTenantDbContext : DbContext
{
private readonly MultiTenants.Fx.Tenant _tenant;
public SingleTenantDbContext(
DbContextOptions<SingleTenantDbContext> options,
ITenantAccessor<MultiTenants.Fx.Tenant> tenantAccessor) : base(options)
{
_tenant = tenantAccessor.Tenant ?? throw new ArgumentNullException(nameof(MultiTenants.Fx.Tenant));
}
public DbSet<Product> Products { get; set; }
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = DateTime.UtcNow;
break;
case EntityState.Modified:
entry.Entity.ModifiedAt = DateTime.UtcNow;
break;
}
}
return base.SaveChangesAsync(cancellationToken);
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer(_tenant.Items["ConnectionString"]?.ToString());
}
}
El contenido de SaveChangesAsync
realmente no es necesario, pero lo quise dejar como en el Post 2, lo importante ocurre en el constructor y en el OnConfiguring
.
OnConfiguring
configurará el contexto como lo hacemos usualmente al registrarlo como dependencia, pero como el Connection String será dinámico, lo leeremos del Tenant
que nos regresa el contenedor de dependencias (todo esto del Post 1).
AuditableEntity
sigue siendo como en el Post 2, pero sin la propiedad TenantId
(ya que no necesitamos una columna con ese dato).
En esta ocasión, agregamos algo nuevo llamado ITenantAccessor
y nos ayuda a acceder al Tenant actual de una forma más elegante y no desde el HttpContext
como lo hicimos en el Post 2.
public interface ITenantAccessor<T> where T : Tenant
{
public T? Tenant { get; init; }
}
public class TenantAccessor : ITenantAccessor<Tenant>
{
public TenantAccessor(IHttpContextAccessor contextAccessor, IConfiguration config, IWebHostEnvironment env)
{
Tenant = contextAccessor.HttpContext?.GetTenant();
if (Tenant is null && env.IsDevelopment())
{
// Nota 👀:
// Si estamos en modo desarrollo y no hay Tenant,
// probablemente es alguna inicialización o creación de migración
// en modo desarrollo
Tenant = new Tenant(-1, "TBD");
Tenant.Items["ConnectionString"] = config.GetConnectionString("SingleTenant");
}
}
public Tenant? Tenant { get; init; }
}
Aquí sucede un truco y si ustedes tienen una mejor solución, háganmelo saber 😅.
Lo que sucede al ejecutar el comando dotnet ef migrations add
se compila la solución y se construye el contexto para ver los cambios que existen en el modelo y cuando ejecutamos dotnet ef database update
consulta la base de datos en físico (la existente en nuestro servidor local) y revisa que migraciones han sido aplicadas.
Por esta razón, necesitamos darle una cadena de conexión de "prueba" o "desarrollo" y se conecte, pero en producción, esto no será así.
{
"ConnectionStrings": {
"TenantAdmin": "Server=(localdb)\\mssqllocaldb;Database=MultiTenant_AdminV2;Trusted_Connection=True;MultipleActiveResultSets=true",
"SingleTenant": "Server=(localdb)\\mssqllocaldb;Database=MultiTenantMultiDb;Trusted_Connection=True;MultipleActiveResultSets=true"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
El contexto del Post 1 (TenantAdminDbContext
) se mantiene igual, solo el Entity Tenant le agregamos una columna ConnectionString
.
public class Tenant
{
public int TenantId { get; set; }
public string? Name { get; set; }
public string? Identifier { get; set; }
public string? ConnectionString { get; set; }
}
Y en el DbContextTenantStore
que también teníamos del Post 1, queda así.
public async Task<Tenant> GetTenantAsync(string identifier)
{
var cacheKey = $"Cache_{identifier}";
var tenant = _cache.Get<Tenant>(cacheKey);
if (tenant is null)
{
var entity = await _context.Tenants
.FirstOrDefaultAsync(q => q.Identifier == identifier)
?? throw new ArgumentNullException($"identifier no es un tenant válido");
tenant = new Tenant(entity.TenantId, entity.Identifier);
tenant.Items["Name"] = entity.Name;
tenant.Items["ConnectionString"] = entity.ConnectionString; // UPDATE ⚠️
_cache.Set(cacheKey, tenant);
}
return tenant;
}
⚠️ Nota: Es importante mencionar que la tabla Tenants de esta base de datos es muy importante tenerla bajo llave, ya que cualquier fuga de información de esta sería muy peligroso porque contiene toda la información de las bases de datos.
Como estamos en .NET 6 (al día de hoy en RC1) necesitamos los siguientes paquetes antes de continuar.
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.0-rc.1.21452.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="6.0.0-rc.1.21452.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.0-rc.1.21452.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
👀 Nota: Se puede utilizar cualquier versión reciente de asp.net core sin problema
Integración con ASP.NET
Aquí hay una diferencia en como configuraremos nuestra base de datos en el Program.cs
builder.Services.AddDbContext<TenantAdminDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("TenantAdmin")));
// NEW!
builder.Services.AddDbContext<SingleTenantDbContext>();
Aquí no estamos especificando que cadena de conexión utilizará ni que proveedor. Esto se hace en el contexto mismo y con Dependency Injection lo hacemos dinámico según el tenant.
Cool ¿verdad?
👀 Nota : Para tener una mejor idea del proyecto completo, revisa mi GitHub
Teniendo ya todo configurado, hay que crear las bases de datos de cada contexto.
dotnet ef migrations add FirstMigration --context SingleTenantDbContext -o Persistence/Migrations/SingleTenant
dotnet ef migrations add FirstMigration --context TenantAdminDbContext -o Persistence/Migrations/TenantAdmin
dotnet ef database update --context SingleTenantDbContext
dotnet ef database update --context TenantAdminDbContext
Esto creará 2 migraciones (y creará 2 bases de datos)
Y según los Connection Strings de appsettings.json, nos creará estas 2 bases de datos.
MultiTenantMultiDb
ya la podríamos usar, pero lo que hice es crear 2 bases de datos aparte copiadas de esta misma (con script o bacpac, hazlo a tu modo).
Finalizando
Para por fin hacer pruebas, tendremos esta información en nuestra tabla de Tenants
Las cadenas de conexión las debes de poner según lo que estés usando (SQL Server Express, SQL Lite, etc)
Y en las BDs individuales, agregamos los productos que queramos para probar.
Como en el Post 2, crearemos una vista sencilla para mostrar los productos.
@page
@model MultiTenantMultiDatabase.Pages.Products.IndexModel
@{
}
<h1>Productos</h1>
<table class="table">
<thead>
<tr>
<th>Product Id</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@foreach (var product in Model.Products)
{
<tr>
<td>@product.ProductId</td>
<td>@product.Description</td>
</tr>
}
</tbody>
</table>
public class IndexModel : PageModel
{
private readonly SingleTenantDbContext _context;
public IndexModel(SingleTenantDbContext context)
{
_context = context;
}
public ICollection<Product> Products { get; set; }
public void OnGet()
{
Products = _context.Products.ToList();
}
}
Como ven, al Entity Products
no se le agrega ningún filtro ya que el DbContext
decidirá automáticamente a que base de datos conectarse.
Conclusión
De esta forma configuramos la infraestructura de nuestra aplicación para que los Developers simplemente se preocupen en las funcionalidades a desarrollar y todo esto sea transparente para ellos.
Cuéntame ¿Qué opinas de estas propuestas para crear aplicaciones Multi-Tenant?
Espero les sea de utilidad, ya que en los últimos proyectos que he diseñado, me he basado en estas dos modalidades habladas en esta serie de posts y me ha funcionado bastante bien ya en proyectos grandes en producción.
Code4Fun 👍🏽.
Referencias
Multi-Tenancy with SQL Server, Part 2: Database Design Approaches
Top comments (10)
Have you thought about a part 4, with integrating per tenant authentication? Combine this with your OpenIddict post and it would be fantastic!
Thank you for your comment, and sounds great, i'll be working on part 4 doing that.
btw, are you having problems following this in spanish?
Thankfully Google translates it well. Though it is a little interesting seeing posts not in english, which is predominantly what you see. Keep up the great work!
The first part is ready here ✌🏽
I think the same!
Hi Isaac. Thank you for a great work!
Did you get any chance to work on part 4 with Identity and authentication ?)
Writing about this it's a little bit more complicated, I'm going to try at least to share the code when i finish it, but thank you for the interest, hope this weekend I'll have time to do it.
I'm working on two more posts, at the moment here you can find a new part.
greetings ✌🏽
Great! Thanks!
Excelente tutorial, ¿es posible relacionar las entidades entre los diferentes contextos conectados a diferentes base de datos?