Skip to main content

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

  1. Always Use Tenant Context

    • Don't bypass tenant filters unless absolutely necessary
    • Use [AllowWithoutTenant] sparingly and document why
    • Audit all cross-tenant operations
  2. Tenant Resolution

    • Choose strategy based on your UI/architecture
    • Host strategy for public SaaS
    • Route strategy for internal apps
    • Claims strategy for APIs/mobile
  3. 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
  4. Testing

    • Test with multiple tenants active
    • Verify tenant isolation in integration tests
    • Check query filters are applied
    • Test cross-tenant scenarios for admins
  5. 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