In this article, we will implement a Multi-Tenant project. In one of our projects, there was a need to implement the system as Multi-Tenant so that the system could be run for multiple customers. Many projects can be run for multiple customers, but the problem is that separate servers, databases, etc., must be created for each customer. Multi-Tenancy solves this problem, allowing you to run one project on a single database while each customer can edit and view their own data. The most critical issue that must be addressed and implemented in such projects is ensuring that each customer can only view their own data and does not have access to other customers' data.

In this project, we used one of the Multi-Tenancy architectures: storing the Tenant in tables. There are several implementations for Multi-Tenancy. In some projects, each customer's database is separated, or based on schema, each customer's tables are separated into a different database. Alternatively, the ID related to each customer is stored in the relevant tables, and filtering is applied to them—this is the method we used.

To do this, we first implemented the EF Core query filters in the DbContext class. At the very beginning, we encountered a problem. We had some existing query filters, and the new EF Core filters were overwriting the previous ones. Therefore, we implemented a method to resolve this issue and allow us to combine the previous query filters with the new ones using an AND operation. By default, EF Core does not support multiple HasQueryFilter on one entity. This limitation prevented the combination of soft delete filters with tenant-specific filters, so we added the following method:

public static void AddQueryFilter<T>(this EntityTypeBuilder<T> entityTypeBuilder, Expression<Func<T, bool>> expression) where T : class
{
    ParameterExpression parameterExpression = Expression.Parameter(entityTypeBuilder.Metadata.ClrType);
    Expression expression2 = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameterExpression, expression.Body);
    LambdaExpression queryFilter = entityTypeBuilder.Metadata.GetQueryFilter();
    
    if (queryFilter != null)
    {
        expression2 = Expression.AndAlso(
            ReplacingExpressionVisitor.Replace(queryFilter.Parameters.Single(), parameterExpression, queryFilter.Body), 
            expression2);
    }

    LambdaExpression filter = Expression.Lambda(expression2, parameterExpression);
    entityTypeBuilder.HasQueryFilter(filter);
}

This extension method combines the existing query filters with the new query filter using an AND operation, and we used it as follows.

modelBuilder.Entity<Organization>()
    .AddQueryFilter(e => _currentTenant.Id == null || e.TenantId == _currentTenant.Id);

modelBuilder.Entity<Employee>()
    .AddQueryFilter(e => _currentTenant.Id == null || e.Organization.TenantId == _currentTenant.Id);

In this section, we have specified that if the current Tenant is not null and has a value, the Tenant-related filter should be applied.

The interesting part of the project that I really liked is here:

ICurrentTenant. We defined an interface to provide the ability to retrieve the current TenantId anywhere in the system or change it (for admin purposes). The CurrentTenant class has a variable of type AsyncLocal that holds the Tenant information (be sure to read about AsyncLocal—it will help you a lot). It also has a property called Id, which returns the current Tenant value, and a method called Change. The beauty of this approach is that this method takes an ID and changes the Tenant value. First, it stores the current Tenant value in a variable, then stores the new Tenant value in the AsyncLocal. If you notice, the return type of this method is IDisposable, meaning it can be used within a using block. We then passed an action to the DisposeAction class, instructing it to restore the previous Tenant value (which we had stored) back into the AsyncLocal when Dispose is called.

public class CurrentTenant : ICurrentTenant
{
    private readonly AsyncLocal<CurrentTenantInfo> _currentScope = new AsyncLocal<CurrentTenantInfo>();

    public int? Id => _currentScope.Value?.Id;

    public IDisposable Change(int? id)
    {
        var parentScope = _currentScope.Value;

        _currentScope.Value = new CurrentTenantInfo(id);

        return new DisposeAction(() =>
        {
            _currentScope.Value = parentScope;
        });
    }
}

 Using this approach, we can change or ignore the Tenant wherever needed, as shown below:

public async Task<List<Organization>> GetAllOrganizationsAsync()
{
    using (currentTenant.Change(null))
    {
        return await GetOrganizationsAsync();
    }
}

Or changing Tenant:

using (currentTenant.Change(5))
{
    var employees = await context.Employees.ToListAsync();
}

Finally, any system that uses Multi-Tenancy must somehow determine which tenant the current request belongs to. In this project, we used a Middleware that reads the tenant value from the header and stores it in ICurrentTenant

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

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

    public async Task InvokeAsync(HttpContext context, ICurrentTenant currentTenant)
    {
        int tenantId = ExtractTenantId(context.Request);

        using (currentTenant.Change(tenantId))
        {
            await _next(context);
        }
    }

    private static int ExtractTenantId(HttpRequest request)
    {
        if (request.Headers.TryGetValue("X-Tenant-Id", out var tenantId))
            return Convert.ToInt32(tenantId);

        throw new ArgumentException("X-Tenant-Id Not found");
    }
}

You can download the code and technical documentation for this article from GitHub.

🔒 Security Considerations

  1. Tenant Isolation: Data is automatically filtered by tenant context
  2. Header Validation: Tenant ID is extracted from request headers
  3. Query Filtering: All database queries are automatically scoped to current tenant
  4. Soft Delete: Deleted records are filtered out automatically
  5. Context Management: Proper disposal of tenant context prevents data leaks

🎯 Key Benefits

  1. Automatic Data Isolation: No manual filtering required
  2. Single Database: Cost-effective and easy to maintain
  3. Thread-Safe: Proper async context management
  4. Flexible: Easy to bypass filters when needed
  5. Performance: Efficient query filtering at database level
  6. Scalable: Easy to add new tenants and entities

🔧 Development Setup

  1. Database: SQL Server with the provided connection string
  2. Migrations: Run dotnet ef database update
  3. Headers: Include X-Tenant-Id in all API requests
  4. Middleware: Ensure TenantMiddleware is registered early in pipeline

This multi-tenant implementation provides a robust, secure, and scalable solution for managing multi-tenant data with automatic isolation and filtering.


Powered by Froala Editor

Comments