Table of Contents

Dependency injection

In ASP.NET Core application, dependency injection (DI) is a fundamental architectural pattern that promotes loose coupling between classes and their dependencies. Central to DI in .NET is the ServiceProvider, a container responsible for managing the lifecycle of services. One of its key features is the ServiceProvider.CreateScope() method, which is essential for managing the scope and lifetime of services, particularly in complex or long-running operations.

Note

It's important to note that this concept is not specific to the Neos framework but is a fundamental aspect of ASP.NET Core. You can find more information of this concept in the references part.

Service provider

The IServiceProvider interface is the root of the dependency injection system in ASP.NET Core. It provides a way to retrieve services from the container and manage their lifetime.

Create scope

The CreateScope() method creates a new scope within the current service provider. This is useful when you need to manage the lifetime of services within a specific scope, such as a request or a transaction.

using IServiceScope scope = serviceProvider.CreateScope();
IUnitOfWork unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>();

Multi-tenant context and background server methods

In a multi-tenant context, you may need to manually register the current tenant when the code is execute in the task runner service. This is the case when it comes to background server method.

public static IServiceScope CreateScopeOnCurrentTenant(this IServiceProvider serviceProvider)
{
    // Create a new scope
    IServiceScope scope = serviceProvider.CreateScope();
    ITenants? tenants = serviceProvider.GetRequiredService<ITenants>();
    if (tenants != null && tenants.MultitenancyEnabled)
    {
        NeosTenantInfo? tenantInfo = tenants.GetCurrentTenant();
        if (tenantInfo != null)
        {
            // Set the current tenant to the tenant service
            scope.ServiceProvider.GetRequiredService<ITenants>()
                .SetCurrentTenant(tenantInfo);
        }
    }
    return scope;
}

Tenant locking mechanism

Overview

Neos implements a tenant locking mechanism to ensure data integrity in multi-tenant environments. When the DbContext is initialized, the current tenant is locked and cannot be changed within the same scope. This prevents accidental data leakage between tenants by ensuring that all database operations in a given scope are performed against a single, consistent tenant.

How it works

The tenant locking mechanism works as follows:

  1. When the DbContext is first initialized (in the OnConfiguring method), the framework automatically calls INeosTenantInfoAccessor.LockTenant().
  2. Once locked, any attempt to change the tenant via ITenants.SetCurrentTenant() or ITenants.SetCurrentTenantAsync() will throw an InvalidOperationException.
  3. The lock is scoped to the current service scope and does not affect other scopes.

This mechanism ensures that once a DbContext is created, it remains bound to a specific tenant for its entire lifetime.

Working with multiple tenants

When you need to perform operations across multiple tenants (for example, querying data from all authorized tenants), you must create a new scope for each tenant. This is because the tenant is locked once the DbContext is initialized in a scope.

Example: Iterating over multiple tenants

public async Task<string> GetCustomerCountByTenant()
{
    if (!_tenants.MultitenancyEnabled)
    {
        return "Multitenancy is not enabled";
    }

    List<CustomerCountByTenant> results = new();
    IEnumerable<AuthorizedTenant> authorizedTenants = await _tenants.GetAuthorizedTenantsAsync();
    
    foreach (AuthorizedTenant authorizedTenant in authorizedTenants)
    {
        // Create a new scope for each tenant to avoid the lock
        using IServiceScope scope = _serviceProvider.CreateScope();
        
        // Get a scoped instance of ITenants
        ITenants scopedTenants = scope.ServiceProvider.GetRequiredService<ITenants>();
        
        // Set the current tenant in this scope
        await scopedTenants.SetCurrentTenantAsync(authorizedTenant.Identifier);
        
        // Get a repository from the scoped provider
        // This will create a DbContext bound to the tenant set above
        int customerCount = await scope.ServiceProvider
            .GetRequiredService<ICustomerRepository>()
            .GetQuery()
            .CountAsync();
            
        NeosTenantInfo? currentTenant = scopedTenants.GetCurrentTenant() ?? NeosTenantInfo.Empty;
        results.Add(new(currentTenant, customerCount));
    }
    
    return FormatResults(results);
}
Important

When working with multiple tenants in a single operation:

  • Always create a new scope using IServiceProvider.CreateScope() for each tenant
  • Use the scoped ITenants instance to set the tenant
  • Retrieve repositories and other services from the scoped provider
  • Dispose of the scope properly (using using statement) to release resources

What happens without scopes?

If you attempt to change tenants without creating new scopes, you'll encounter an InvalidOperationException:

// ❌ INCORRECT - This will throw an exception
foreach (AuthorizedTenant tenant in authorizedTenants)
{
    await _tenants.SetCurrentTenantAsync(tenant.Identifier); // First iteration: OK
    var data = await _repository.GetQuery().ToListAsync(); // DbContext initialized, tenant locked
    // Second iteration: SetCurrentTenantAsync will throw InvalidOperationException
}

Error message: The tenant cannot be changed after the DbContext has been initialized in the current scope.

// ✅ CORRECT - Create a new scope for each tenant
foreach (AuthorizedTenant tenant in authorizedTenants)
{
    using IServiceScope scope = _serviceProvider.CreateScope();
    ITenants scopedTenants = scope.ServiceProvider.GetRequiredService<ITenants>();
    await scopedTenants.SetCurrentTenantAsync(tenant.Identifier);
    var data = await scope.ServiceProvider
        .GetRequiredService<IRepository>()
        .GetQuery()
        .ToListAsync();
}

Why is tenant locking necessary?

The tenant locking mechanism prevents several potential issues:

  1. Data integrity: Prevents accidental mixing of data from different tenants in a single database context
  2. Security: Ensures that database queries cannot inadvertently access data from the wrong tenant
  3. Consistency: Guarantees that all operations within a scope are performed against the same tenant
  4. Connection management: The DbContext connection string is determined when the context is configured based on the current tenant, changing the tenant afterwards would create an inconsistent state

References