PearDrop Multi-Tenancy
PearDrop provides enterprise-grade multi-tenancy supporting multiple isolation strategies, tenant resolution methods, and flexible data partitioning.
What is Multi-Tenancy?
Multi-tenancy allows a single application instance to serve multiple customers (tenants) with complete data isolation and customization:
- Tenant Isolation - Each customer's data is completely separated
- Shared Infrastructure - Single codebase and deployment serves all tenants
- Per-Tenant Customization - Features, branding, and configuration per tenant
- Scalability - Add new tenants without deploying new infrastructure
Architecture Overview
User Request (tenant1.app.com)
↓
Tenant Resolution Middleware (Finbuckle)
↓
Identify Tenant (tenant1)
↓
Load Tenant Info from Database
↓
Set Tenant Context (TenantId, ConnectionString, Schema, etc.)
↓
Apply Tenant Filters to Queries (automatic)
↓
Request Processed (only sees tenant1 data)
Finbuckle.MultiTenant Integration
PearDrop uses Finbuckle.MultiTenant library for tenant management:
- Industry-standard multi-tenancy framework
- Flexible tenant resolution strategies
- Tenant stores (database, in-memory, custom)
- ASP.NET Core middleware integration
- Support for multiple data isolation approaches
Tenant Resolution Strategies
1. Host Strategy (Subdomain)
Identify tenant by subdomain:
https://tenant1.myapp.com → Tenant: tenant1
https://acmecorp.myapp.com → Tenant: acmecorp
https://admin.myapp.com → Owner/system admin (no tenant)
Configuration:
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy();
Best for:
- SaaS products with custom domains
- B2B applications
- Professional service offerings
2. Route Strategy (URL Path)
Identify tenant from URL path segment:
https://myapp.com/tenant1/dashboard → Tenant: tenant1
https://myapp.com/acmecorp/settings → Tenant: acmecorp
https://myapp.com/admin → Owner/system (no tenant)
Configuration:
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithRouteStrategy("tenant"); // Route parameter name
Best for:
- Internal enterprise applications
- Applications with single domain
- Multi-tenant APIs
3. Authentication Strategy (Claims)
Identify tenant from authentication claims:
User signs in → JWT/Cookie contains TenantId claim
Request authenticated → Tenant extracted from claims
Configuration:
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithClaimStrategy("TenantId"); // Claim type
Best for:
- Mobile applications
- SPAs with token authentication
- Microservices with JWT
4. Combined Strategy
Use multiple strategies with fallback:
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy() // Try subdomain first
.WithRouteStrategy("tenant") // Fallback to route
.WithClaimStrategy("TenantId"); // Final fallback to claims
Data Isolation Strategies
Shared Database with Tenant Filter
Most common approach - All tenants in one database with TenantId column:
-- Every table has TenantId
CREATE TABLE Orders (
Id uniqueidentifier PRIMARY KEY,
TenantId uniqueidentifier NOT NULL, -- Automatic filter
OrderNumber nvarchar(50),
...
);
-- Queries automatically filtered
SELECT * FROM Orders; -- Only returns current tenant's orders
Pros:
- Simple deployment and migrations
- Efficient resource usage
- Easy cross-tenant reporting (for admins)
- Cost-effective
Cons:
- All tenants affected by downtime
- Schema changes affect all tenants
- Careful with query filters (data leak risk)
Schema-Per-Tenant
Each tenant gets own database schema:
-- Tenant1 schema
CREATE SCHEMA tenant1;
CREATE TABLE tenant1.Orders (...);
-- Tenant2 schema
CREATE SCHEMA tenant2;
CREATE TABLE tenant2.Orders (...);
Configuration:
public class Tenant : IAggregateRoot
{
public string SchemaName { get; set; } = "dbo";
public TenantIsolationLevel IsolationLevel { get; set; } = TenantIsolationLevel.Schema;
}
Pros:
- Better isolation than shared database
- Single connection pool
- Tenant-specific schema customization
Cons:
- Complex migrations
- Limited by database schema limits
- More complex queries
Database-Per-Tenant
Each tenant gets dedicated database:
-- Tenant databases
Tenant1Database
Tenant2Database
AcmeCorpDatabase
Configuration:
public class Tenant : IAggregateRoot
{
public string ConnectionString { get; set; }
public TenantIsolationLevel IsolationLevel { get; set; } = TenantIsolationLevel.Database;
}
Pros:
- Maximum isolation
- Independent scaling per tenant
- Tenant-specific backups/restores
- Different SQL Server editions per tenant
Cons:
- Complex connection management
- Higher infrastructure costs
- Difficult cross-tenant reporting
Tenant Model
Tenant Aggregate
public class Tenant : IAggregateRoot
{
public Guid Id { get; set; } // Unique tenant ID
public string Identifier { get; set; } // URL-friendly identifier (slug)
public string Reference { get; set; } // Customer reference number
public string Name { get; set; } // Display name
public DateTime? WhenDisabled { get; set; } // Disabled timestamp
// Data isolation
public TenantIsolationLevel IsolationLevel { get; set; }
public string? ConnectionString { get; set; } // For database-per-tenant
public string? SchemaName { get; set; } // For schema-per-tenant
// Features
public IReadOnlyList<string> SystemFeatures { get; } // Enabled features
public IReadOnlyList<TenantMetaItem> MetaItems { get; } // Custom metadata
// Per-tenant settings
public bool UseMfa { get; } // Require MFA
public bool UseDeviceRemembrance { get; } // Remember devices
public int DeviceRemembranceExpirationInDays { get; } // Trust duration
}
TenantInfo (Runtime Context)
public class PearDropTenantInfo : ITenantInfo
{
public string Id { get; set; } // Tenant GUID as string
public string Identifier { get; set; } // URL slug
public string Name { get; set; } // Display name
public string ConnectionString { get; set; }
public string? SchemaName { get; set; }
}
Configuration
appsettings.json
{
"ConnectionStrings": {
"PearDrop-Multitenancy": "Server=localhost,1433;Database=MyApp;..."
},
"PearDrop": {
"modules": {
"multitenancy": {
"defaultTenantId": "00000000-0000-0000-0000-000000000000",
"defaultIdentifier": "dbo",
"defaultName": "Default Tenant",
"ownerHost": "admin.myapp.com",
"tenantSiteUrl": "https://{tenant}.myapp.com",
"noTenantRedirectPath": "/no-tenant",
"doNotUseStore": false
}
}
}
}
Service Registration
Program.cs:
// Add PearDrop multitenancy services
builder.Services.AddPearDropMultitenancy(builder.Configuration);
// Configure tenant resolution
builder.Services.AddMultiTenant<PearDropTenantInfo>()
.WithHostStrategy() // Resolve from subdomain
.WithStore<CustomMultiTenantStore>(); // Store tenants in database
var app = builder.Build();
// Add multitenancy middleware (MUST be early in pipeline)
app.UseMultiTenant();
app.MapControllers();
app.AddMultitenancyApi();
app.Run();
Automatic Tenant Filtering
Entity Framework Integration
All query operations automatically filtered by tenant:
// Query without explicit tenant filter
var orders = await dbContext.Orders
.Where(o => o.Status == "Pending")
.ToListAsync();
// PearDrop automatically adds:
// WHERE TenantId = @CurrentTenantId AND Status = 'Pending'
Read Models
Read models automatically tenant-isolated:
var orders = await readModels.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync();
// Tenant filter applied automatically
// User only sees their tenant's orders
Cross-Tenant Operations
Admin Scenarios
System administrators need access to all tenants:
// Bypass tenant filter for admin queries
[AllowWithoutTenant]
public class AdminReportQuery : IQuery<AdminReportResult>
{
public DateOnly StartDate { get; set; }
public DateOnly EndDate { get; set; }
}
// Query handler can see all tenants
public class AdminReportQueryHandler : IQueryProcessor<AdminReportQuery, AdminReportResult>
{
public async Task<QueryResult<AdminReportResult>> Handle(
AdminReportQuery request,
CancellationToken cancellationToken)
{
// No tenant filter - sees all tenant data
var orders = await readModels.Orders.ToListAsync();
return QueryResult<AdminReportResult>.Succeeded(...);
}
}
Tenant Data Extraction
Export all data for specific tenant:
var extractionService = serviceProvider.GetRequiredService<ITenantDataExtractionService>();
var exportData = await extractionService.ExtractTenantDataAsync(
tenantId: tenantId,
cancellationToken: cancellationToken);
// Returns JSON with all tenant data:
// - Users
// - Orders
// - Settings
// - Metadata
Commands
Create Tenant
var command = new CreateTenantCommand(
identifier: "acmecorp", // URL slug
reference: "CUST-12345", // Customer ID
name: "Acme Corporation");
var result = await commandRunner.ExecuteAsync(command);
if (result.Success)
{
var tenantId = result.TenantId;
// Tenant created and ready to use
}
Update Tenant
var command = new UpdateTenantDetailsCommand(
tenantId: tenantId,
identifier: "acme",
name: "Acme Corp");
await commandRunner.ExecuteAsync(command);
Enable/Disable Tenant
// Disable tenant (blocks all access)
var disableCommand = new DisableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(disableCommand);
// Enable tenant
var enableCommand = new EnableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(enableCommand);
Configure Features
var command = new SetSystemFeaturesCommand(
tenantId: tenantId,
features: new[]
{
"AdvancedReporting",
"APIAccess",
"CustomBranding"
});
await commandRunner.ExecuteAsync(command);
Per-Tenant Settings
// MFA setting
var mfaCommand = new UpdateTenantMetaItemsCommand(
tenantId: tenantId,
metaItems: new Dictionary<string, string>
{
[TenantMetaItemKeys.UseMfa] = "true"
});
await commandRunner.ExecuteAsync(mfaCommand);
// Device remembrance
var deviceCommand = new SetTenantDeviceRemembranceSettingsCommand(
tenantId: tenantId,
useDeviceRemembrance: true,
expirationInDays: 30);
await commandRunner.ExecuteAsync(deviceCommand);
Integration Events
Tenant Created
Published when new tenant is created:
public record TenantCreatedIntegrationEvent(
Guid TenantId,
string Identifier,
string Name,
DateTime CreatedAt) : IIntegrationEvent;
Subscribe to provision tenant resources:
[CapSubscribe(MultitenancyCapTopics.TenantCreated)]
public async Task Handle(TenantCreatedIntegrationEvent @event)
{
// Provision tenant resources
await CreateTenantStorageAsync(@event.TenantId);
await SendWelcomeEmailAsync(@event.TenantId);
await InitializeTenantSettingsAsync(@event.TenantId);
}
Best Practices
-
Always Use Tenant Context
- Don't bypass tenant filters unless absolutely necessary
- Use
[AllowWithoutTenant]sparingly and document why - Audit all cross-tenant operations
-
Tenant Resolution
- Choose strategy based on your UI/architecture
- Host strategy for public SaaS
- Route strategy for internal apps
- Claims strategy for APIs/mobile
-
Data Isolation
- Start with shared database + tenant filter
- Move high-value clients to database-per-tenant
- Use schema-per-tenant for moderate isolation
- Never mix isolation strategies in single tenant
-
Testing
- Test with multiple tenants active
- Verify tenant isolation in integration tests
- Check query filters are applied
- Test cross-tenant scenarios for admins
-
Performance
- Index TenantId column on all tables
- Consider partitioning large tables by TenantId
- Cache tenant info to reduce database lookups
- Monitor per-tenant resource usage
Next Steps
- Configuration & Settings - Configure tenant resolution and data isolation
- Tenant Resolution - Deep dive into resolution strategies
- Tenant Management - CRUD operations and lifecycle
- Data Isolation - Schema and database-per-tenant patterns
- Best Practices - Production deployment patterns