Building Multitenant App using Azure Cosmos DB in Clean Architecture

Discuss how to build a multitenant web API application with partition per tenant data isolation using Azure Cosmos DB in Clean Architecture.

Shawn Shi
Geek Culture
10 min readMar 1, 2023

--

Multitenancy using Azure Cosmos DB in Clean Architecture (CA image credit to Microsoft documentation)

Background

When building multi-tenant applications using Azure Cosmos DB (or other NoSQL databases), there are multiple popular data isolation models:

  • Partition key per tenant
  • Container per tenant
  • Database per tenant

Microsoft has a detailed documentation, Multitenancy and Azure Cosmos DB, covering how Azure Cosmos DB can support different isolation models in full technical terms. In my previous article, Understanding Multitenancy Isolation Models in Plain English, we also discussed different isolation models at the high-level in plain English (but fun approach).

How do we actually build one multi-tenant application with partition per tenant isolation model using Azure Cosmos DB?

Goal

Let’s assume we are building a simplified ticket tracking platform, i.e., a SaaS product similar to Jira or Azure DevOps. It should allow individual businesses to manage their support tickets. Each business will be a tenant. Within each tenant, the platform allows users to

  • manage support tickets, such as creating tickets, assigning tickets, and updating ticket status.
  • manage Wiki pages like “how to fix problem ABC”.

The goal of this article is to demonstrate how to implement partition per tenant data isolation model using Azure Cosmos DB following Clean Architecture. By the end of this article, we should be able to support the following workflow.

Workflow diagram by author.

Clean Architecture

If you need a refresher on what is Clean Architecture, here is a great read: Common web application architectures. The advantage of Clean Architecture is the ability to separate business entities and business service definitions in the Core class library, separate implementation details like Cosmos DB SDK in the Infrastructure class library, and separate presentation layer in the API or UI application.

Data Modeling

As mentioned, we have two business entities to start with: Ticket and Wiki Page.

In a SQL relational database world, each entity like ticket and wiki page may have their own tables, and are optionally related to some other tables using foreign keys.

However, in NoSQL world such as Azure Cosmos DB, we can fit multiple entities into the same database container, as long as they share the same partitioning strategy. Because Cosmos DB is horizontally scalable, the single container will not run into scalability issue or storage size issue, assuming your partition key strategy gives you a high cardinality, i.e., a large number of different partition key values.

In Azure Cosmos DB, each logical partition has a size limit of 20G at the time of this article. Be aware of the size limit. If a single tenant needs more than 20G storage, you can use a composite / surrogate key as the partition key instead of a simple tenant id, such as tenant id + resource type. For the discussion purpose, we will use tenant id for simplicity. It should be easy enough to just swap the partition key format for your own scenario.

We will take advantage of the schema-less feature in Cosmos DB. We can simplify our data modeling work by using a single container called “Data” to host all ticket data and wiki page data. You might think this is too simple. That’s right, in fact, it is simple! A couple of take away notes:

  1. Single container called Data, and its partition key is set to be tenant id. Tenant id is likely a GUID. This means every single tenant will have its own logical partition.
  2. As mentioned above, if you need more than 20G storage size per tenant, use a surrogate key, {tenant Id}:{suffix} as the partition key instead of only the tenant id. For example, partition key value can be “tenantId1:ticket” for ticket data, and “tenantId1:wiki” for wiki page data, which will give you 20G for ticket data, and 20G for wiki page data.

On a side note, if you want to read more about data modeling best practices using Azure Cosmos DB, here are a couple of articles to get started.

Now let’s build it...

Getting Started

Prerequisites for local development:

Let’s follow the Clean Architecture development workflow:

  1. Setup Core class library
  2. Setup Infrastructure class library.
  3. Setup web API
  4. (Optional) Auto-create Azure Cosmos DB database and containers

Here is a high-level overview of the folder structure.

Step 1 — Setup Core class library

Two major components are defined in the Core project:

  • Entities: Ticket, Wiki Page. Both inherit the BaseEntity. They define what actual data we are working with.
  • Persistence interfaces: IRepository, ITicketRepository and IWikiPageRepository. They define the contract on how we interact with the persistence layer. Note we only have interfaces, as we should not know the concrete implementation at the Core level yet. Note ITicketRepository only defines the type it works with and has minimal code, because everything is defined in IRepository. Beauty!
public abstract class BaseEntity
{
[JsonProperty(PropertyName = "id")]
public virtual string Id { get; set; }

public virtual string TenantId { get; set; }
}
public class Ticket : BaseEntity
{
/// <summary>
/// Name of support ticket.
/// </summary>
public string Name { get; set; }

/// <summary>
/// Ticket status.
/// </summary>
public TicketStatus Status { get; set; }

/// <summary>
/// User who is assigned to this ticket.
/// </summary>
public string Assignee { get; set; }
}
public interface IRepository<T> where T : BaseEntity
{
/// <summary>
/// Get one item by Id
/// </summary>
Task<T> GetItemAsync(string id);

Task AddItemAsync(T item);

Task UpdateItemAsync(string id, T item);

Task DeleteItemAsync(string id);

/// <summary>
/// Get items by providing a query string.
/// </summary>
/// <param name="queryString"></param>
/// <returns></returns>
Task<IEnumerable<T>> GetItemsAsync(string queryString);
}
/// <summary>
/// Repository definition for Ticket.
/// </summary>
public interface ITicketRepository : IRepository<Ticket>
{ }

Step 2 — Setup Infrastructure class library

This is probably the bread and butter part for this article, since we are focusing on Azure Cosmos DB.

First, let’s implement the IRepository interface defined above, as a abstract class called CosmosDbRepository. Note this is an abstract class only, as it is not specific to any entities. But we want all entity-specific repositories to know the following:

  • What Cosmos DB container to to work with, hence the ContainerName and _container properties below.
  • What id value to use for a newly created item. GenerateId allows us to create a new item without manually creating an item id. Id is required by the Cosmos DB.
  • How to retrieve the partition key for a query to the database. ResolvePartitionKey method allows us to retrieve the partition key value from the item id.
  • How to interact with the database. The CosmosClient comes from the Azure Cosmos DB SDK. Microsoft Cosmos DB SDK documentation recommends a singleton client instance to be used throughout the application. We will discuss how to register the Cosmos DB SDK client.
  • Because we are using a generic type T here, the implementation for methods like AddItemAsync() should work for all entity-specific repositories. I encourage you to pause a second here, and think how great it is!
/// <summary>
/// Abstract repository to work with Cosmos DB.
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class CosmosDbRepository<T> : IRepository<T> where T : BaseEntity
{
/// <summary>
/// Actual Cosmos DB container that allows us to interact with the database.
/// </summary>
private readonly Microsoft.Azure.Cosmos.Container _container;

/// <summary>
/// Name of the CosmosDB container we are working with.
/// </summary>
public abstract string ContainerName { get; }

/// <summary>
/// Constructor
/// </summary>
/// <param name="cosmosClient">Singleton instance of the CosmosClient from SDK.</param>
public CosmosDbRepository(Microsoft.Azure.Cosmos.CosmosClient cosmosClient)
{
this._container = cosmosClient.GetContainer(CosmosDbConstants.DatabaseName, ContainerName);
}

/// <summary>
/// Generate id for an item.
/// </summary>
/// <param name="entity"></param>
/// <returns></returns>
public abstract string GenerateId(T entity);

/// <summary>
/// Resolve the partition key from the item id.
/// </summary>
/// <param name="entityId"></param>
/// <returns></returns>
public abstract Microsoft.Azure.Cosmos.PartitionKey ResolvePartitionKey(string entityId);

public async Task AddItemAsync(T item)
{
item.Id = GenerateId(item);
await _container.CreateItemAsync<T>(item, ResolvePartitionKey(item.Id));
}

public async Task DeleteItemAsync(string id)
{
await this._container.DeleteItemAsync<T>(id, ResolvePartitionKey(id));
}

public async Task<T> GetItemAsync(string id)
{
try
{
ItemResponse<T> response = await _container.ReadItemAsync<T>(id, ResolvePartitionKey(id));
return response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
}

public async Task UpdateItemAsync(string id, T item)
{
// Update
await this._container.UpsertItemAsync<T>(item, ResolvePartitionKey(id));
}

// Search data using SQL query string
public async Task<IEnumerable<T>> GetItemsAsync(string queryString)
{
FeedIterator<T> resultSetIterator = _container.GetItemQueryIterator<T>(new QueryDefinition(queryString));
List<T> results = new List<T>();
while (resultSetIterator.HasMoreResults)
{
FeedResponse<T> response = await resultSetIterator.ReadNextAsync();

results.AddRange(response.ToList());
}

return results;
}
}

Now let’s look at a concrete implementation for entity Ticket. In the code below, we defines:

  • Ticket data will be stored in container named “Data”.
  • A ticket item will have an id that’s two parts, tenant id + GUID.
  • Give an item id, how we can build the partition key, which is required to query the Cosmos DB.
public class TicketRepository : CosmosDbRepository<Ticket>, ITicketRepository
{
/// <summary>
/// Name of the Cosmos DB container where ticket data is stored.
/// </summary>
public override string ContainerName => CosmosDbConstants.DataContainerName;

/// <summary>
/// Constructor
/// </summary>
/// <param name="cosmosClient"></param>
public TicketRepository(CosmosClient cosmosClient) : base(cosmosClient)
{
}


public override string GenerateId(Ticket entity)
{
// Two parts: tenant id, new guid.
return $"{entity.TenantId}:{Guid.NewGuid()}";
}

public override PartitionKey ResolvePartitionKey(string entityId)
{
// First part of the item id.
string[] idComponents = entityId.Split(':');
return new PartitionKey(idComponents[0]);
}
}

Step 3 — Setup web API

We are almost there! Let’s first setup the entry point for the web API, Program.cs.

using Infrastructure.Persistence.CosmosDb.Extensions;
using WebAPI.Config;

IConfiguration configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json", true,
true)
.AddCommandLine(args)
.AddEnvironmentVariables()
.Build();


var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

// Cosmos DB for application data
builder.Services.SetupCosmosDb(configuration);

// API controllers
builder.Services.AddControllers();

// Swagger UI
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
// Ensure Cosmos DB is created and optionally seeded.
app.EnsureCosmosDbIsCreated().Wait();
app.SeedDataContainerIfEmptyAsync().Wait();

app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Note on top of typical API setup code, we have the following lines to deal with Cosmos DB.

  • SetupCosmosDb method, which registers the Cosmos DB SDK client as a singleton instance, and also our repositories as scoped instances.
  • EnsureCosmosDbIsCreated method, which checks if the database and container(s) exist at the start time of the application. If not, create them.
public static void SetupCosmosDb(this IServiceCollection services, IConfiguration configuration)
{
// Bind database-related bindings
CosmosDbSettings cosmosDbConfig = configuration.GetSection("ConnectionStrings:CosmosDB").Get<CosmosDbSettings>();
// register CosmosDB client and data repositories
services.AddCosmosDb(cosmosDbConfig.EndpointUrl,
cosmosDbConfig.PrimaryKey);

services.AddScoped<ITicketRepository, TicketRepository>();
// Audience: for you to follow through the code.
//services.AddScoped<IWikiPageRepository, WikiPageRepository>();
}
/// <summary>
/// Ensure Cosmos DB is created
/// </summary>
/// <param name="builder"></param>
public static async Task EnsureCosmosDbIsCreated(this IApplicationBuilder builder)
{
using (IServiceScope serviceScope = builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
// This should be a singleton instance
Microsoft.Azure.Cosmos.CosmosClient cosmosClient = serviceScope.ServiceProvider.GetService<Microsoft.Azure.Cosmos.CosmosClient>();

// Create the database
Microsoft.Azure.Cosmos.DatabaseResponse database = await cosmosClient.CreateDatabaseIfNotExistsAsync(CosmosDbConstants.DatabaseName);

// Create the container(s)
await database.Database.CreateContainerIfNotExistsAsync(CosmosDbConstants.DataContainerName, CosmosDbConstants.DataContainerPartitionKey);
}
}

Final piece, our controller! We will keep the controller simple. We have one sample endpoint implemented.

  • Get a single item by id.
/// <summary>
/// Ticket controller
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class TicketController : ControllerBase
{
private readonly ITicketRepository _ticketRepository;

public TicketController(ITicketRepository ticketRepository)
{
this._ticketRepository = ticketRepository ?? throw new ArgumentNullException(nameof(ticketRepository));
}

// GET: api/Ticket/5
/// <summary>
/// Get by id
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}", Name = "GetTicket")]
[ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))]
public async Task<ActionResult<TicketModel>> Get(string id)
{
Core.Entities.Ticket ticket = await _ticketRepository.GetItemAsync(id);

if (ticket == null)
{
return NotFound();
}

return new TicketModel()
{
Id = ticket.Id,
TenantId = ticket.TenantId,
Name = ticket.Name,
Status = ticket.Status,
Assignee = ticket.Assignee
};

}

}

On a side note, If you would like to see how advanced patterns like CQRS and MediatR can be used in controllers, please refer to this article, or this full featured GitHub repo.

Demo time!

We should be really happy now that we have got here! If we run the Azure Cosmos DB emulator, and then the WebAPI application, it should create our database and containers, seed data, and expose our single REST API endpoint.

Let’s look at our seeded ticket item from the Azure Cosmos DB. Note the id has two parts, first part is exactly as the tenant id, and the second part is a random GUID.

Let’s copy the id value and use it on our web API swagger page. We should be able to retrieve the ticket through the GET endpoint. Hoooray!

Follow up questions

Where should we store the actual tenants? There are a couple of options here.

  • If you are planning to use a relational SQL database like Azure SQL for user membership management, it is a good idea to manage tenants there. That allows you to know exactly which tenant a user belongs to, and makes retrieving tenant id easy since you can add a tenant claim to the access token or cookies in your authentication workflow. Another option is to add another container to the Cosmos DB database above, and store tenants data there.

Would you like to follow through the code so you can build your own? In the sample code above, SetupCosmosDb method has two lines commented out. If you uncomment them, you can try to follow the workflow above (Core -> Infrastructure -> Web API) and see if you can build an end-to-end feature for Wiki pages.

// Audience: for you to follow through the code.
//services.AddScoped<IWikiPageRepository, WikiPageRepository>();

Conclusions

Congratulations! We have successfully implemented partition per tenant isolation model using Azure Cosmos DB following Clean Architecture. This should be a good starting point for any new applications that aim to take advantage of Clean Architecture and Cosmos DB.

The full working application code can be found on this GitHub repo.

If you are interested in a more full-blown solution using Azure Cosmos DB, please refer to this GitHub repo, which supports additional features like:

  • Automatic auditing
  • Partitioned repository pattern
  • Fully developed REST API application using MediatR, CQRS, centralized exception handling, etc..
  • Azure Functions
  • SPA client

Related resources:

This is part of a series of articles discussing multitenant applications. Other articles can be found here:

  1. Understanding Multitenancy Isolation Models in Plain English

--

--

Shawn Shi
Geek Culture

Senior Software Engineer at Microsoft. Ex-Machine Learning Engineer. When I am not building applications, I am playing with my kids or outside rock climbing!