Multi-Tenancy with RavenDB and ASP.NET Core

07/02/2023
.NETRavenDBC#

Multi-tenancy is a software architecture pattern where a single instance of a software application is used by multiple customers, with each customer having separate and isolated data, configurations, and resources. RavenDB is a NoSQL document database that provides a flexible and scalable solution for multi-tenant applications. This blog post will explore why multi-tenancy exists, the advantages of using RavenDB for multi-tenant applications, and provide code examples to get you started.

Why Multi-Tenancy exists

Multi-tenancy exists because it allows organizations to efficiently manage and allocate resources to multiple customers, reducing the costs of software development and maintenance. This approach is especially useful for SaaS (Software as a Service) companies that offer their services to many customers over the internet.

As you might know, I live and work in Switzerland, and there are specific data protection laws in place. So imagine you have your infrastructure in Switzerland (it doesn't matter if cloud or on-premise), and you are storing sensitive data (like financial data or medical records). The law dictates that this data is not allowed to leave Switzerland and no one from the outside (of Switzerland) is allowed to see that data. If you have branches all over the world, multi-tenancy can give you an easy way of achieving those requirements, as each branch might be its own tenant in your database!

Tenants

RavenDB

I will not go into much detail, what RavenDB is, because their website does a great job with that (see "Get Started"). It also shows you how to setup your local environment.

Why do I highlight RavenDB here? There are several advantages:

  • Scalability: RavenDB is designed to scale horizontally, making it easy to add more resources to the system as the number of customers increases.
  • Flexibility: RavenDB is a NoSQL database, meaning that it can store any kind of data in a document format, making it easy to accommodate different data structures and configurations for each customer.
  • Performance: RavenDB uses an in-memory cache and indexing to provide fast and efficient data access, even when dealing with large amounts of data.
  • Security: RavenDB provides multi-tenancy support out of the box, allowing you to create separate databases for each customer and control access to those databases based on user roles and permissions.

And the last point is very important. Why? Let's discover

How to

There are multiple ways how to achieve multi-tenancy inside your application. Of course, each approach has its ups and downs, so let's discuss some obvious solutions to that problem.

You can have a single shared database. That means every tenant is also inside that database. The advantage of that approach is, that your administration cost is fairly low. You have one instance (with multiple nodes) with one SSL certificate and all the users in your database. It also makes it easier if you have shared data that is valid for all tenants. The tricky part is more or less having a tenant Id in those objects/documents where it matters.

The second approach, and the one I'll highlight here, is having one database per tenant. It seems like more stuff to do, or? Not really. From a code point of view, that is fairly simple with RavenDB. Also, scaling horizontally is easier if you have one database per tenant. You can have different "CPU power" aka nodes in your cluster per tenant. Another point is user management. Because the databases are isolated, you can also have different users and roles in those databases, making managing users and permissions easier.

Another reason to go with the latter approach in most cases is that you can better separate your concerns. You can, for example, write a middleware in ASP.NET Core that gets the tenant id and prepares everything so that your controller doesn't have to distinguish between tenants.

Here is a general flow of what we are going to do:

Sequence

Talk is cheap - show me the code

Let's begin like the flow chart shows. The user sends a request and we want to retrieve the tenant id in our middleware to set up everything.

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ITenantSetter tenantSetter)
    {
        // Check the headers and retrieve the TenantId
        // If there is none - return a 400 immediately
        var tenantId = context.Request.Headers["TenantId"];

        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("TenantId header is required");
            return;
        }

        // You can also have a mapping here, or check if that tenant exists in the first place
        tenantSetter.SetTenant(tenantId);

        await _next(context);
    }
}

Nothing out of the ordinary - we unpack the headers and put the tenant id into a service. The service is quite easy:

public interface ITenantSetter
{
    void SetTenant(string tenant);
}

public interface ITenantGetter 
{
    string Tenant { get; }
}

public class TenantService : ITenantGetter, ITenantSetter
{
    public string Tenant { get; private set; }
    
    public void SetTenant(string tenant)
    {
        Tenant = tenant;
    }
}

Okay that is quite straightforward. One puzzle piece done, let's continue. The heavy lifting is still coming. What we need is a factory that creates us the IDocumentStore depending on the tenant id passed to it. As this operation is not super cheap, we will keep those instances around in a dictionary and we will also register this later as a singleton.

public interface IDocumentStoreFactory
{
    IDocumentStore GetStore(string tenantId);
}

public class DocumentStoreFactory : IDocumentStoreFactory
{
    private readonly ConcurrentDictionary<string, Lazy<IDocumentStore>> _stores;

    public DocumentStoreFactory()
    {
        _stores = new ConcurrentDictionary<string, Lazy<<IDocumentStore>>();
    }

    public IDocumentStore GetStore(string tenantId)
    {
        if (_stores.TryGetValue(tenantId, out Lazy<IDocumentStore> value))
        {
            return value.Value;
        }

        var store = new DocumentStore
        {
            Urls = new[] { "http://localhost:8080" },
            Database = tenantId
        };

        store.Initialize();

        _stores[tenantId] = new Lazy<store>;

        return store;
    }
}

Okay, the next thing we have to do is put those pieces together. The first thing we can do is make it easier for our controllers to access those IDocumentStores. Right now, a controller would have to recheck the request header and get the right IDocumentStore from our recently created class, but that is not great at all. Remember SOLID principles? Especially the Single responsibility principle? Yeah - then the controller should not do so many things at once. To overcome that, we will create a nice facade for depending services:

public interface ITenantDocumentStore
{
    IDocumentStore DocumentStore { get; }
}

public class TenantDocumentStore : ITenantDocumentStore
{
    public TenantDocumentStore(string tenantId, IDocumentStoreFactory factory)
    {
        DocumentStore = factory.GetStore(tenantId);
    }

    public IDocumentStore DocumentStore { get; }
}

Nice - our controller will use that interface and doesn't have to know anything about specific tenant ids. To bring everything together we can utilize the DI container:

// Per request we want to have the same underlying service
// for TenantGetter and TenantSetter
builder.Services.AddScoped<TenantService>();
builder.Services.AddScoped<ITenantGetter>(r => r.GetRequiredService<TenantService>());
builder.Services.AddScoped<ITenantSetter>(r => r.GetRequiredService<TenantService>());

// Register our tenant services for RavenDB
// The factory should be singleton over the whole lifetime so that we don't create
// new IDocumentStores for every request
builder.Services.AddSingleton<IDocumentStoreFactory, DocumentStoreFactory>();
builder.Services.AddScoped<ITenantDocumentStore>(x =>
{
    var tenantId = x.GetRequiredService<ITenantGetter>().Tenant;
    return new TenantDocumentStore(tenantId, x.GetService<IDocumentStoreFactory>());
});

// ...

// Use our tenant middleware
app.UseMiddleware<TenantMiddleware>();

Oh yes the last piece is missing, the controller itself - but that is quite straightforward:

[ApiController]
[Route("blogposts")]
public class BlogPostController : ControllerBase
{
    private readonly ITenantDocumentStore _documentStore;

    public BlogPostController(ITenantDocumentStore documentStore)
    {
        _documentStore = documentStore;
    }

    [HttpGet]
    [Route("list")]
    public async Task<List<BlogPost>> Get()
    {
        using var session = _documentStore.DocumentStore.OpenAsyncSession();
        return await session.Query<BlogPost>().ToListAsync();
    }

    [HttpPost]
    [Route("add")]
    public async Task<IActionResult> Add([FromBody] CreateBlogPostRequest request)
    {
        using var session = _documentStore.DocumentStore.OpenAsyncSession();
        await session.StoreAsync(new BlogPost() { Title = request.Title });
        await session.SaveChangesAsync();

        return Ok();
    }
}

Perfect. We did it! To test this, create two databases on your RavenDB server. I created an "A" and "B" database. By the way you can optimize that code a bit. For example, the ITenantDocumentStore could directly offer an OpenAsyncSession method instead of returning the IDocumentStore, but I hope you get the idea.

Results

Let's try out what we created. First, the obvious candidate: A request without any tenant id should return a 400:

No Tenant Id

As expected we get an error when we don't provide the tenant id, so what happens if we do?

Tenant

We ask for all blog posts on tenant "B", as we did not add any until now the list is empty. So let us add some on instance "A" and some on instance "B". We can do this via postman and call the route /blogPosts/add.

Now if we ask on "B" for a list of all blog posts, we can get something like that:

[
    {
        "id": "BlogPosts/1-B",
        "title": "Steven on B"
    }
]

Conclusion

With a few simple "tricks" we can easily make multi-tenancy work with RavenDB and ASP.NET Core. We used the ASP.NET Core request pipeline to set a tenant id during a request - and the best thing, our controllers don't really know about that! Win-win!

Resources

  • Source code to this blog post: here
  • All my sample code is hosted in this repository: here
5
An error has occurred. This application may no longer respond until reloaded. Reload x