DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on • Edited on

ASP.NET Core 6: Multi-tenant Single Database (Parte 2)

Introducción

Seguimos con la continuación de esta serie de posts sobre aplicaciones multi-tenant con asp.net core.

En este post veremos como implementar una aplicación multi-tenant con una base de datos que contenga todos los tenants.

Veremos las ventajas y desventajas y la razón de porque podríamos utilizar este approach.

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:

Tipos de multi-tenancy single database

Como vimos en el post pasado, podemos tener distintas arquitecturas al crear aplicaciones SaaS. En este caso vamos a hablar sobre como tener múltiples tenants en una misma base de datos.

Cabe mencionar que es importante analizar si nos conviene esta modalidad ya que tiene sus desventajas pero en ciertos escenarios, sus ventajas.

Siempre debemos de analizar que necesitamos, porque al guardar información de nuestros usuarios, debemos considerar siempre la seguridad, mantenibilidad y escalabilidad.

Una base de datos lenta hace un sistema lento. Una base de datos insegura, filtra información. Una base de datos inmantenible, no dará soluciones a futuro.

Schema-based.

Una sola base de datos para todos los tenants y cada tenant tiene su propio esquema en la base de datos.

Untitled

Seguridad

  • ✔️ La información entre tenants se mantiene aislada, ya que cada uno tendrá su propio esquema.
  • ✔️ Filtrar información entre tenants tiene un riesgo bajo, ya que no necesitas de un WHERE para limitar la información entre Tenants.
  • ❌ Aun así, puedes hacer queries al esquema equivocado y consultar un tenant totalmente diferente.

Mantenibilidad

  • ✔️ Manejar una base de datos sigue siendo una ventaja al darle mantenimiento.
  • ✔️ También, al tener esquemas diferentes podemos tener un scope de cada tenant.
  • ❌ Actualizar los esquemas será doloroso, más si se tienen muchos tenants
  • ❌ No se puede restaurar un solo tenant, ya que sigue siendo una sola base de datos
  • ❌ Agregar nuevos tenants involucra agregar un esquema totalmente nuevo, una BD con muchas tablas será problema

Escalabilidad

  • ✔️ La información está particionada en tablas pequeñas
  • ✔️ Optimizar tablas se podría hacer por tenant
  • ❌ Al ser una sola base de datos y un servidor, se limita la escalabilidad a un solo hardware
  • ❌ Riesgo de "noisy neighbors" — Un tenant puede impactar el rendimiento de otros tenants

Table-based.

De igual forma, una base de datos para todos los tenants y todos comparten el mismo esquema. La forma de aislar los tenants se realiza por medio de un Identificador en todas las tablas.

Personalmente prefiero esta modalidad si usamos Single database, y en este post haremos un ejemplo usando esta modalidad.

Untitled 1

Seguridad

  • ❌ Existe un riesgo alto de exponer información entre tenants pero existen varias mitigaciones para eliminar este riesgo.
    • ✔️ El ejemplo que haremos en este post, será configurar Entity Framework para que haga Queries de forma automática según el tenant actual, reduciendo a 0 el riesgo de filtración a nivel aplicación.
  • ❌ No existe aislamiento entre tenants

Mantenibilidad

  • ✔️ Fácil de mantener por ser una sola base de datos con un solo esquema
  • ✔️ Fácil de recuperar en caso de un desastre o caídas prolongadas
  • ✔️ Agregar tenants nuevos es fácil, no hay que hacer ninguna modificación al esquema.
  • ❌ Queries pueden ser riesgosos de modificar Tenants no deseados
    • Mitigación: Row-Level Security puede ser usado para controlar el acceso de los rows en las tablas
  • ❌ Si usamos RLS, se debe de actualizar en cada tabla nueva que agregamos
  • ❌ Sería difícil restaurar información de un solo tenant.

Escalabilidad

  • ❌ Solo se podría hacer scale-up (agregar más hardware) y no distribuirlo de forma horizontal (scale-out)
  • ❌ Mismo problema de "Noisy neighbors"
  • ❌ Si la base de datos crece (cada tenant tiene mucha información) las actividades de mantenimiento tardarán más y potencialmente afectará a otros tenants

¿Por qué la modalidad table-based?

Realmente por experiencias que he tenido en microservicios, me gusta esta modalidad.

Sí debemos de tener en cuenta los factores mencionados arriba, una base de datos grande y con mucha información por tenant, definitivamente ninguna de estas será la solución.

Para estos casos, el siguiente post con multi-database será una solución muy buena. Pero para bases de datos relativamente pequeñas, sí es una buena opción.

Un microservicio usualmente tiene esquemas pequeños, eso lo hace fácil de manejar. Podría ser que las tablas sí crezcan mucho, pero al ser pocas tablas de manejar, podría ser viable alguna de estas dos opciones.

MultiTenant DbContext (una DB para todos 🧙🏽‍♂️)

Para continuar con este ejemplo en ASP.NET Core, tenemos que seguir utilizando la solución del ejemplo anterior (la dejamos preparada para lo mismo).

Para comenzar, crearemos un nuevo DbContext en la solución con una tabla de ejemplo Product.



public class Product : AuditableEntity
{
    public int ProductId { get; set; }
    public string Description { get; set; }
}


Enter fullscreen mode Exit fullscreen mode

La clase padre AuditableEntity nos servirá para obligar que todos los Entities tengan ciertas propiedades y eso nos ayudará a crear un aislamiento y manejo de nuestros Entities.



public class AuditableEntity
{
    public int TenantId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ModifiedAt { get; set; }
}


Enter fullscreen mode Exit fullscreen mode

Aquí simplemente estamos agregando el campo TenantId (es obligatorio para todos los entities) y además unas propiedades adicionales para control de ellas.

Aquí podemos agregar Ids de usuarios que crearon/modificaron los registros para que realmente sea un Entity Auditable.



using Microsoft.EntityFrameworkCore;
using Z.EntityFramework.Plus;

public class MultiTenantDbContext : DbContext
{
    private readonly int _tenantId;

    public MultiTenantDbContext(
        DbContextOptions<MultiTenantDbContext> options,
        IHttpContextAccessor contextAccessor) : base(options)
    {
        var currentTenant = contextAccessor.HttpContext?.GetTenant();
        _tenantId = currentTenant?.Id ?? 0;

        this.Filter<AuditableEntity>(f => f.Where(q => q.TenantId == _tenantId));
    }

    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.TenantId = _tenantId;
                    entry.Entity.CreatedAt = DateTime.UtcNow;
                    break;
                case EntityState.Modified:
                    entry.Entity.ModifiedAt = DateTime.UtcNow;
                    break;
            }
        }

        return base.SaveChangesAsync(cancellationToken);
    }
}


Enter fullscreen mode Exit fullscreen mode

Aquí se complicaron un poco las cosas. Pero antes de explicarlo, necesitamos una librería que nos ayude con este truco de EF Core.



<PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="6.0.0-preview.7.21378.4-4" />


Enter fullscreen mode Exit fullscreen mode

Lo que está sucediendo aquí realmente es sencillo:

Primero que nada, utilizando IHttpContextAccessor estamos accediendo al HttpContext de la solicitud actual y a su vez nos da el Tenant que se quiere acceder.

Si no existe ningún HttpContext (puede ser alguna inicialización o similar) de entrada no se podrán hacer Queries ya que es requisito saber que Tenant se está accediendo para poder mostrar información.

Tal vez aquí no es lo más optimo, talvez podemos crear un ITenantAccessor y llenarlo de formas personalizadas (y no siempre de un HttpContext) pero por ahora, el ejemplo será así.

En el mismo constructor de MultiTenantDbContext se está configurando para que siempre agregue una cláusula WHERE a cualquier query que se haga en el contexto, esto utilizando la librería Z.EntityFramework.Plus.EFCore.

Esta es la parte donde nos aseguramos que exista un aislamiento (al menos uno lógico) y se puedan realizar Queries de forma segura.

ℹ️ Nota: Cabe mencionar, que esta medida de seguridad solo funciona si se usa el DbContextdirectamente, cualquier raw SQL está potencialmente propenso a fugas de información.

El método SaveChangesAsync simplemente es para actualizar cualquier Entity que se haya creado o modificado. Esto no tiene mucho que ver con la BD multi-tenant, pero es buena idea que las operaciones sean auditables.

Integración con ASP.NET

Para poder crear la base de datos y empezar a hacer pruebas, tenemos que registrar este nuevo DbContext.



builder.Services.AddDbContext<MultiTenantDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("MultiTenant")));


Enter fullscreen mode Exit fullscreen mode

Teniendo ya las siguientes connection strings.



"ConnectionStrings": {
    "TenantAdmin": "Server=(localdb)\\mssqllocaldb;Database=MultiTenant_Admin;Trusted_Connection=True;MultipleActiveResultSets=true",
    "MultiTenant": "Server=(localdb)\\mssqllocaldb;Database=MultiTenantSingleDb;Trusted_Connection=True;MultipleActiveResultSets=true"
  }


Enter fullscreen mode Exit fullscreen mode

Como en el post anterior, ejecutamos los siguientes comandos para crear la primera migración y crear la base de datos.



dotnet ef database add FirstMigration --context MultiTenantDbContext -o Persistence/Migrations/MultiTenant
dotnet ef database update --context MultiTenantDbContext


Enter fullscreen mode Exit fullscreen mode

Como ya contamos con dos DbContext en el proyecto, cada comando ef que hagamos, debemos especificar a que contexto nos estamos refiriendo.

Y nos creará lo siguiente.

Untitled 2

Untitled 3

Es por eso que desde el post pasado, especificamos el output de la migración.

¿Por qué dos bases de datos?

Es recomendable tener una Base de datos "compartida" o "principal" para que en esta podamos guardar meta-data. Es decir, información que describan nuestros tenants, catálogos compartidos y entre otras cosas.

En multi-database esta base de datos compartida será de vital importancia.

Además, para agregar tenants nuevos, solo tendríamos que agregar un registro a la tabla Tenants dentro del contexto TenantAdminDbContext.

Finalizando

Para hacer pruebas, agregaremos datos de prueba.

Untitled 4

Desde el post anterior estamos utilizando Razor Pages, por lo que haremos una consulta directamente en nuestro PageModel (en este caso, uno llamado Products.cshtml) y así comprobar que cuando estamos en localhost, solo nos debe de regresar Product 1 y Product 2.



using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;

public class ProductsModel : PageModel
{
    private readonly MultiTenantDbContext _context;

    public ProductsModel(MultiTenantDbContext context)
    {
        _context = context;
    }

    public List<Product> Products { get; set; }

    public async Task OnGet()
    {
        Products = await _context.Products.ToListAsync();
    }
}


Enter fullscreen mode Exit fullscreen mode

Como pueden ver aquí, simplemente estamos enlistando todos los productos de la tabla, aquí se encuentran mezclados entre tenants, pero el Filter base que pusimos hará que estén aislados.



@page
@model MultiTenantSingleDatabase.Pages.ProductsModel
@{
}

<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>


Enter fullscreen mode Exit fullscreen mode

Como resultado.

Untitled 5

Si visitamos otro tenant.

Untitled 6

Conclusión

Podríamos pensar que esta modalidad de hacer aplicaciones multi-tenants tiene más desventajas que ventajas. Pero si analizamos la situación correctamente, podría ser nuestro proyecto candidato para hacer un multi-tenancy con single database y funcionar perfectamente.

Cuéntame ¿Cómo ves está opción? yo soy totalmente C# Developer y manejar todo desde C# siempre es lo que hago, crear Raw SQL queries es algo que no hago muy seguido.

Y recuerda,

Code4Fun 👍🏽.

Update 1 ℹ️

En lugar de utilizar el NuGet Package de Z.EntityFramework (que para otras cosas también es útil) no sabía que EF Core ya cuenta con una funcionalidad así incluida pero al parecer se tendría que poner el "Query Filter" por cada Entity y no de su clase base como lo hacemos originalmente, igual es bueno saberlo 👍🏽.

Dale un vistazo -> Global Query Filters

Referencias

Multi-tenant Application Database Design

Multi-Tenancy with SQL Server, Part 2: Database Design Approaches

Multi-tenant SaaS patterns - Azure SQL Database

Top comments (3)

Collapse
 
sebaspaladino profile image
Seba Paladino

Excelente post! Realmente muy claro. Hay algun aproach para hacer algo parecido pero basado en el usuario que se loguea en el sistema.
Anteriormente hice sistemas asi, (en .net framework y con ayuda de la sesion) pero esto es mucho mas elegante y limpio de lo que venia haciendo.

Gracias!

Collapse
 
jesusmurua profile image
Mr.Robot

Muy buen tutorial, me ha servido bastante. Una pequeña consulta, en la base de datos "compartida" ¿me conviene tener las tablas de usuarios, roles, etc?
espero puedas responder, saludos.

Collapse
 
isaacojeda profile image
Isaac Ojeda

Qué bueno que te ha gustado!

El principal problema de tener Usuarios en la BD compartida son los constraints que frameworks como Identity Core agrega. Para identity core no se deben de repetir los UserNames. Pero en este caso no se deben de repetir por tenant aunque estén en la misma tabla.

Si eso se "configura" o ni se usará Identity Core, no debe de haber problemas. Yo sí he usado Identity Core en apps multi-tenant en la misma aplicación. El "hack" fue que para respetar ese constraint, sería anexar el tenant ID en cada user name y al mostrarlo en alguna parte del UI, pues quitamos ese ID. No es lo más elegante, pero funcionó.

¡Un saludo!