ワンクリックで
multi-tenancy
Use when adding multi-tenant support with tenant isolation, per-tenant databases, or query filters.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
メニュー
Use when adding multi-tenant support with tenant isolation, per-tenant databases, or query filters.
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
SOC 職業分類に基づく
Use when designing gRPC services, proto files, or adding gRPC-Web or JSON transcoding.
Use when writing async code, propagating CancellationTokens, or fixing async/await pitfalls.
Use when applying or enforcing C# coding style — namespaces, sealed classes, var usage, XML docs.
Use when applying modern C# idioms — records, pattern matching, primary constructors, collection expressions.
Use when registering services, choosing lifetimes, or implementing DI patterns like decorator or keyed services.
Use when selecting or implementing design patterns in C# — factory, builder, strategy, decorator, or mediator.
| name | multi-tenancy |
| description | Use when adding multi-tenant support with tenant isolation, per-tenant databases, or query filters. |
| metadata | {"category":"architecture","agent":"dotnet-architect"} |
| when_to_use | When implementing multi-tenant architecture, tenant isolation, or tenant resolution |
Resolve the tenant early in the pipeline. Support multiple strategies.
// Abstractions/ITenantProvider.cs
public interface ITenantProvider
{
Guid TenantId { get; }
string TenantName { get; }
}
// Abstractions/ITenantEntity.cs
public interface ITenantEntity
{
Guid TenantId { get; }
}
// Infrastructure/TenantProvider.cs
internal sealed class TenantProvider : ITenantProvider
{
public Guid TenantId { get; set; }
public string TenantName { get; set; } = default!;
}
// Infrastructure/Middleware/TenantResolutionMiddleware.cs
internal sealed class TenantResolutionMiddleware(
RequestDelegate next,
ITenantStore tenantStore,
ILogger<TenantResolutionMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
var tenantId = ResolveTenantId(context);
if (tenantId is null)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(
new { error = "Tenant could not be resolved" });
return;
}
var tenant = await tenantStore.GetByIdAsync(tenantId.Value);
if (tenant is null)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(
new { error = "Tenant not found" });
return;
}
var provider = context.RequestServices
.GetRequiredService<TenantProvider>();
provider.TenantId = tenant.Id;
provider.TenantName = tenant.Name;
logger.LogDebug("Resolved tenant {TenantId} ({TenantName})",
tenant.Id, tenant.Name);
await next(context);
}
private static Guid? ResolveTenantId(HttpContext context)
{
// Strategy 1: JWT claim
var claim = context.User.FindFirst("tenant_id");
if (claim is not null && Guid.TryParse(claim.Value, out var fromClaim))
return fromClaim;
// Strategy 2: Custom header
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var header)
&& Guid.TryParse(header, out var fromHeader))
return fromHeader;
// Strategy 3: Subdomain (e.g., acme.app.com)
var host = context.Request.Host.Host;
var subdomain = host.Split('.')[0];
if (Guid.TryParse(subdomain, out var fromSubdomain))
return fromSubdomain;
// Strategy 4: Route or query parameter
if (context.Request.Query.TryGetValue("tenantId", out var qs)
&& Guid.TryParse(qs, out var fromQuery))
return fromQuery;
return null;
}
}
// Abstractions/ITenantStore.cs
public interface ITenantStore
{
Task<TenantInfo?> GetByIdAsync(Guid tenantId, CancellationToken ct = default);
Task<TenantInfo?> GetByHostAsync(string hostname, CancellationToken ct = default);
}
// Domain/TenantInfo.cs
public sealed class TenantInfo
{
public Guid Id { get; init; }
public string Name { get; init; } = default!;
public string? ConnectionString { get; init; }
public string? SchemaName { get; init; }
public bool IsActive { get; init; } = true;
}
// Infrastructure/Persistence/TenantDbContextFactory.cs
internal sealed class TenantDbContextFactory(
ITenantProvider tenantProvider,
ITenantStore tenantStore) : IDbContextFactory<AppDbContext>
{
public async Task<AppDbContext> CreateDbContextAsync(
CancellationToken ct = default)
{
var tenant = await tenantStore.GetByIdAsync(
tenantProvider.TenantId, ct)
?? throw new InvalidOperationException("Tenant not found");
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(tenant.ConnectionString)
.Options;
return new AppDbContext(options, tenantProvider);
}
// Synchronous overload required by IDbContextFactory
public AppDbContext CreateDbContext()
=> CreateDbContextAsync().GetAwaiter().GetResult();
}
// Infrastructure/Persistence/SchemaPerTenantDbContext.cs
internal sealed class SchemaPerTenantDbContext(
DbContextOptions<SchemaPerTenantDbContext> options,
ITenantProvider tenantProvider,
ITenantStore tenantStore) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
var tenant = tenantStore.GetByIdAsync(tenantProvider.TenantId)
.GetAwaiter().GetResult();
var schema = tenant?.SchemaName ?? "dbo";
modelBuilder.HasDefaultSchema(schema);
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(SchemaPerTenantDbContext).Assembly);
}
}
// Infrastructure/Persistence/AppDbContext.cs
internal sealed class AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantProvider tenantProvider) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly);
// Global query filter — every query is automatically tenant-scoped
modelBuilder.Entity<Order>()
.HasQueryFilter(e => e.TenantId == tenantProvider.TenantId);
modelBuilder.Entity<Product>()
.HasQueryFilter(e => e.TenantId == tenantProvider.TenantId);
}
public override Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
// Stamp TenantId on new entities automatically
foreach (var entry in ChangeTracker.Entries<ITenantEntity>()
.Where(e => e.State == EntityState.Added))
{
entry.Entity.GetType()
.GetProperty(nameof(ITenantEntity.TenantId))!
.SetValue(entry.Entity, tenantProvider.TenantId);
}
return base.SaveChangesAsync(cancellationToken);
}
}
// Infrastructure/Persistence/Extensions/ModelBuilderExtensions.cs
internal static class ModelBuilderExtensions
{
public static void ApplyTenantQueryFilters(
this ModelBuilder modelBuilder, ITenantProvider tenantProvider)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (!typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
continue;
var parameter = Expression.Parameter(entityType.ClrType, "e");
var tenantIdProp = Expression.Property(parameter,
nameof(ITenantEntity.TenantId));
var tenantIdValue = Expression.Property(
Expression.Constant(tenantProvider),
nameof(ITenantProvider.TenantId));
var filter = Expression.Lambda(
Expression.Equal(tenantIdProp, tenantIdValue), parameter);
entityType.SetQueryFilter(filter);
}
}
}
// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
public static IServiceCollection AddMultiTenancy(
this IServiceCollection services, IConfiguration configuration)
{
// Tenant provider — scoped so it lives for the request
services.AddScoped<TenantProvider>();
services.AddScoped<ITenantProvider>(sp =>
sp.GetRequiredService<TenantProvider>());
// Tenant store — singleton with caching
services.AddSingleton<ITenantStore, CachedTenantStore>();
// DbContext — scoped, receives tenant from provider
services.AddDbContext<AppDbContext>((sp, options) =>
{
var tenantProvider = sp.GetRequiredService<ITenantProvider>();
var tenantStore = sp.GetRequiredService<ITenantStore>();
var tenant = tenantStore.GetByIdAsync(tenantProvider.TenantId)
.GetAwaiter().GetResult();
if (tenant?.ConnectionString is not null)
{
// Database-per-tenant
options.UseSqlServer(tenant.ConnectionString);
}
else
{
// Shared database fallback
options.UseSqlServer(
configuration.GetConnectionString("Default"));
}
});
return services;
}
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMultiTenancy(builder.Configuration);
var app = builder.Build();
app.UseMiddleware<TenantResolutionMiddleware>();
app.MapEndpointGroups();
app.Run();
// Infrastructure/TenantConnectionFactory.cs
internal sealed class TenantConnectionFactory(
ITenantProvider tenantProvider,
ITenantStore tenantStore,
IConfiguration configuration)
{
public async Task<string> GetConnectionStringAsync(
CancellationToken ct = default)
{
var tenant = await tenantStore.GetByIdAsync(
tenantProvider.TenantId, ct);
return tenant?.ConnectionString
?? configuration.GetConnectionString("Default")
?? throw new InvalidOperationException(
"No connection string configured");
}
}
| Criterion | Database-per-Tenant | Schema-per-Tenant | Shared + Discriminator |
|---|---|---|---|
| Data isolation | Strongest — separate databases | Moderate — separate schemas | Weakest — row-level |
| Cost | Highest — one DB per tenant | Moderate — one DB, N schemas | Lowest — one DB, one schema |
| Complexity | High — connection management, migrations per tenant | Moderate — schema management | Low — query filters only |
| Compliance | Best for regulated industries (HIPAA, SOC 2) | Acceptable for most | Depends on auditor |
| Cross-tenant queries | Hard — requires federation | Moderate — schema prefix | Easy — remove filter |
| Tenant onboarding | Slow — provision database | Moderate — create schema + migrate | Fast — insert row |
| Backup/restore per tenant | Easy — backup one database | Moderate — schema export | Hard — extract rows |
| Performance isolation | Full — no resource contention | Partial — shared I/O | None — shared everything |
| Max tenants | Hundreds | Thousands | Unlimited |
| Anti-Pattern | Risk | Fix |
|---|---|---|
| No global query filter | Data leak — tenant sees other tenant's data | Apply HasQueryFilter on every ITenantEntity |
| Hardcoded tenant ID | Testing/debugging shortcut becomes production bug | Always resolve from ITenantProvider |
| Missing tenant context in background jobs | Jobs run without HTTP context, tenant is null | Pass TenantId explicitly to job payload and restore provider |
IgnoreQueryFilters() without re-adding tenant filter | Bypasses all filters including tenant scoping | Add explicit .Where(e => e.TenantId == tenantId) when using IgnoreQueryFilters() |
| Tenant resolution after authentication middleware | Auth may fail because tenant DB is not yet known | Place tenant resolution before auth when tenant drives connection |
| Caching without tenant key | Shared cache returns wrong tenant's data | Always include TenantId in cache keys |
| Seeding shared lookup data with tenant ID | Reference data duplicated per tenant | Use a separate unfiltered DbSet for shared lookups |
| Connection string in logs | Credentials exposed in structured logs | Sanitize or exclude connection strings from log output |
TenantId property on entities or a base classHasQueryFilter calls in OnModelCreatingITenantProvider, ITenantAccessor, or ITenantContext interfacesIDbContextFactory is used for per-tenant connection managementX-Tenant-Id header usage in middleware or delegating handlersITenantEntity interface with Guid TenantId and add it to all tenant-scoped entitiesITenantProvider and TenantProvider — register as scopedOnModelCreating for every ITenantEntitySaveChangesAsync to auto-stamp TenantId on new entitiesTenantId column migration to all existing tenant-scoped tablesTenantId for existing rows — use a default tenant for migrationTenantId in every cache entryTenantId in job payload, restore context before execution