Skip to main content
Version: 3.1.2

Multi-Tenancy Overview

Overview

Multi-tenancy support in RCommon allows a single application instance to serve multiple tenants while keeping their data isolated. The framework provides a thin abstraction layer over third-party tenant resolution libraries so that your domain and persistence code remains unaware of how tenants are identified.

The design is split across two concerns:

  • Tenant resolution — determining which tenant is active for the current request. This is delegated to a provider (currently Finbuckle) that inspects the HTTP context, claims, route data, or any other signal you configure.
  • Tenant filtering — automatically scoping repository queries and entity stamps to the active tenant. This happens inside RCommon's repository implementations whenever the entity implements IMultiTenant.

Core abstractions

ITenantIdAccessor is the single point of integration between tenant resolution and the persistence layer. Its GetTenantId() method is called by repositories whenever they need to apply tenant isolation. The default registration (NullTenantIdAccessor) returns null, which disables filtering entirely — useful during bootstrapping or when multi-tenancy is not yet configured.

IMultiTenantBuilder is the fluent builder interface that all provider-specific builders implement. It gives providers access to the DI service collection so they can replace NullTenantIdAccessor with their own implementation.

WithMultiTenancy<TBuilder> is the extension method on IRCommonBuilder that wires a provider into the RCommon startup pipeline. It constructs the builder by convention (via Activator.CreateInstance) and passes it to the configuration action you supply.

Entity-level isolation

Any entity that implements IMultiTenant participates in automatic isolation:

using RCommon.Entities;

public class Invoice : BusinessEntity<Guid>, IMultiTenant
{
public string Number { get; set; } = string.Empty;
public decimal Amount { get; set; }

// Populated automatically by the repository on add/update.
public string? TenantId { get; set; }
}

At query time the repository reads the current TenantId from ITenantIdAccessor and appends a filter expression so only records belonging to that tenant are returned. At write time the same value is stamped onto the entity before it is persisted.

When GetTenantId() returns null or an empty string all tenant filtering is bypassed, which is intentional for administrative scenarios where a privileged caller needs cross-tenant access.

Stamping semantics

The exact guard applied when a repository writes an entity (MultiTenantHelper.SetTenantIdIfApplicable) is:

  • If the resolved tenant ID is null or empty, stamping is skipped entirely — the entity's existing TenantId (if any) is left untouched, not overwritten with null.
  • If the resolved tenant ID is non-empty, it unconditionally overwrites whatever the entity's TenantId already was.

This asymmetry is what makes TenantScope.Bypass() (below) safe to use for the tenant-bootstrap scenario: suspending resolution down to null for the scope's lifetime means stamping is skipped, so an aggregate's explicitly-set TenantId survives AddAsync unmodified.

Installation

NuGet Package
dotnet add package RCommon.MultiTenancy

A provider package is also required. For Finbuckle:

NuGet Package
dotnet add package RCommon.Finbuckle

Configuration

Call WithMultiTenancy<TBuilder> inside your RCommon startup block, passing the concrete builder type for the provider you are using:

using RCommon;
using RCommon.Finbuckle;
using Finbuckle.MultiTenant;

// Configure the tenant resolution strategy (Finbuckle's own API).
builder.Services.AddMultiTenant<TenantInfo>()
.WithHeaderStrategy("X-Tenant-Id")
.WithConfigurationStore();

// Wire Finbuckle into RCommon.
builder.Services.AddRCommon(config =>
{
config
.WithClaimsAndPrincipalAccessorForWeb()
.WithPersistence<EFCorePersistenceBuilder>(ef =>
{
ef.AddDbContext<AppDbContext>(
"App",
options => options.UseSqlServer(connectionString));
})
.WithMultiTenancy<FinbuckleMultiTenantBuilder<TenantInfo>>(mt =>
{
// FinbuckleTenantIdAccessor is registered automatically.
// No further configuration is required here unless you need
// to register custom services against the builder.
});
});
Modular composition

WithMultiTenancy<TBuilder> is a cache-aware sub-builder verb. When multiple modules call WithMultiTenancy<FinbuckleMultiTenantBuilder<TenantInfo>>(...) with the same builder type, the cached builder is reused and each module's configuration action runs against the same instance. FinbuckleTenantIdAccessor is registered exactly once. This lets modules independently add tenant-aware services (custom tenant stores, per-tenant options) without coordinating wiring. See Modular Composition for the full conflict matrix.

Usage

Tenant-scoped entities

Mark entities with IMultiTenant to opt into automatic isolation:

public class Product : BusinessEntity<int>, IMultiTenant
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }

// Set automatically; do not assign manually in normal usage.
public string? TenantId { get; set; }
}

Repository reads are filtered to the current tenant transparently:

// Returns only the current tenant's products.
ICollection<Product> products = await _repository.FindAsync(
p => p.Price > 0, cancellationToken);

Repository writes stamp the current tenant ID automatically:

// TenantId is populated from ITenantIdAccessor before INSERT.
await _repository.AddAsync(new Product { Name = "Widget", Price = 9.99m }, cancellationToken);

Bypassing tenant isolation for one call: TenantScope.Bypass()

Swapping in a different ITenantIdAccessor registration is an all-or-nothing, DI-configuration-time change — it affects every request for the life of the process. Most cross-tenant needs are narrower than that: one specific call (list every tenant a super-admin belongs to, create the first row for a brand-new tenant) that should skip ambient scoping without touching anything else.

TenantScope.Bypass() (RCommon.Security.Claims) is a scoped, per-call primitive for exactly this. While the scope returned by Bypass() is active — including across await continuations, via AsyncLocal<bool> — any accessor wrapped with TenantScopeAwareTenantIdAccessor resolves to null, which every repository already treats as "skip filtering / skip stamping":

using RCommon.Security.Claims;

public class TenantAdminService
{
private readonly IGraphRepository<Tenant> _tenants;

public async Task<ICollection<Tenant>> ListAllTenantsAsync(CancellationToken ct)
{
// Requires the caller to already be authorized for cross-tenant access --
// TenantScope.Bypass() has no authorization check of its own.
using (TenantScope.Bypass())
{
return await _tenants.FindAsync(t => true, ct);
}
}
}

Always dispose the returned handle — a using block is strongly recommended. If it is never disposed (e.g. an exception path that skips the using block due to a bug in calling code), the bypass remains active for the rest of the current logical call context. Nested scopes compose correctly: disposing an inner scope while an outer scope is still active restores "bypassed," not "not bypassed."

RCommon wraps its own built-in accessors with TenantScopeAwareTenantIdAccessor automatically at their existing registration sites — ClaimsTenantIdAccessor (via WithClaimsAndPrincipalAccessor()) and FinbuckleTenantIdAccessor<TTenantInfo> (via FinbuckleMultiTenantBuilder<TTenantInfo>) both honor TenantScope.Bypass() with no code change required. NullTenantIdAccessor is intentionally not wrapped, since it always returns null already.

Worked scenario: tenant bootstrap

Creating the very first row for a brand-new tenant is the one case where you want an explicitly-set TenantId to survive AddAsync untouched — not stamped with null, and not stamped with whatever tenant the caller happens to be ambiently scoped to:

public class CreateTenantCommandHandler
{
private readonly IAggregateRepository<Tenant, Guid> _tenants;

public async Task HandleAsync(CreateTenantCommand cmd, CancellationToken ct)
{
using (TenantScope.Bypass())
{
var tenant = new Tenant(cmd.NewTenantId, cmd.Name);
// tenant.TenantId is set explicitly by the aggregate's own constructor. With the
// bypass active, ITenantIdAccessor.GetTenantId() resolves to null, so the stamping
// guard above skips stamping entirely -- the explicitly-set TenantId is left as-is.
await _tenants.AddAsync(tenant, ct);
}
}
}

Opting in a custom ITenantIdAccessor

If you've written your own ITenantIdAccessor implementation instead of using ClaimsTenantIdAccessor or Finbuckle's accessor, wrap it with TenantScopeAwareTenantIdAccessor at your own registration site to get the same bypass support — RCommon has no way to intercept a registration it doesn't own:

services.AddTransient<MyCustomTenantIdAccessor>();
services.AddTransient<ITenantIdAccessor>(sp =>
new TenantScopeAwareTenantIdAccessor(sp.GetRequiredService<MyCustomTenantIdAccessor>()));
Authorization is the caller's responsibility

TenantScope.Bypass() removes tenant isolation for its scope with no authorization check of its own — the same trust model as ITenantIdAccessor itself. Gate any code path that calls Bypass() behind your own authorization check (e.g. an admin role). RCommon has no way to know which callers should be allowed to see cross-tenant data.

API Summary

TypePackageDescription
ITenantIdAccessorRCommon.SecurityReturns the current tenant ID; null disables all filtering
NullTenantIdAccessorRCommon.SecurityDefault implementation; always returns null
ClaimsTenantIdAccessorRCommon.SecurityReads tenant ID from the authenticated user's claims
TenantScopeRCommon.SecurityBypass() suspends tenant scoping for a scoped, ambient, per-call lifetime
TenantScopeAwareTenantIdAccessorRCommon.SecurityDecorator making any ITenantIdAccessor honor TenantScope.Bypass()
IMultiTenantBuilderRCommon.MultiTenancyBuilder interface that provider packages implement
MultiTenancyBuilderExtensionsRCommon.MultiTenancyProvides WithMultiTenancy<TBuilder>() on IRCommonBuilder
IMultiTenantRCommon.EntitiesEntity marker interface; exposes string? TenantId
RCommonRCommon