Tenant Management
Perform CRUD operations and manage tenant lifecycle using PearDrop commands.
Creating Tenants
Basic Tenant Creation
var command = new CreateTenantCommand(
identifier: "acmecorp", // URL-friendly, unique
reference: "CUST-12345", // Your internal customer ID
name: "ACME Corporation" // Display name
);
var result = await commandRunner.ExecuteAsync(command, cancellationToken);
if (result.IsSuccess)
{
var tenantId = result.Data.TenantId;
Console.WriteLine($"Created tenant: {tenantId}");
}
else
{
Console.WriteLine($"Error: {result.Error.Message}");
}
Command result:
public sealed record CreateTenantCommandResult(Guid TenantId);
Identifier Requirements
Valid identifiers:
acmecorp✅client-123✅tenant_one✅abc123✅
Invalid identifiers:
ACME Corp❌ (spaces)acme@corp❌ (special chars except-and_)123❌ (too short, varies by validation rules)- Capital letters may be auto-lowercased
Best practices:
- Lowercase alphanumeric with dashes/underscores
- 3-50 characters
- Unique across all tenants
- Match your subdomain naming conventions
Provisioning Workflow
public async Task<Guid> ProvisionNewTenant(
string companyName,
string customerRef,
TenantIsolationLevel isolationLevel,
CancellationToken cancellationToken)
{
// 1. Generate identifier from company name
var identifier = GenerateIdentifier(companyName); // "ACME Corp" → "acmecorp"
// 2. Create tenant
var createCommand = new CreateTenantCommand(identifier, customerRef, companyName);
var createResult = await commandRunner.ExecuteAsync(createCommand, cancellationToken);
if (createResult.IsFailure)
throw new Exception($"Failed to create tenant: {createResult.Error.Message}");
var tenantId = createResult.Data.TenantId;
// 3. Configure isolation level
if (isolationLevel != TenantIsolationLevel.Shared)
{
var configCommand = new SetTenantIsolationCommand(
tenantId,
isolationLevel,
schemaName: isolationLevel == TenantIsolationLevel.Schema ? identifier : null,
connectionString: isolationLevel == TenantIsolationLevel.Database
? GenerateConnectionString(identifier)
: null
);
await commandRunner.ExecuteAsync(configCommand, cancellationToken);
}
// 4. Set system features
var featuresCommand = new SetSystemFeaturesCommand(
tenantId,
new List<string> { "Tasks", "Reports", "Export" }
);
await commandRunner.ExecuteAsync(featuresCommand, cancellationToken);
// 5. Configure MFA settings
var mfaCommand = new SetTenantMfaSettingsCommand(
tenantId,
requireMfa: false,
allowDeviceRemembrance: true,
deviceRemembranceDays: 30
);
await commandRunner.ExecuteAsync(mfaCommand, cancellationToken);
return tenantId;
}
private string GenerateIdentifier(string companyName)
{
return companyName
.ToLowerInvariant()
.Replace(" ", "")
.Replace(".", "")
.Replace(",", "");
}
private string GenerateConnectionString(string identifier)
{
var config = configuration.GetConnectionString("TenantTemplate");
return config.Replace("{TenantId}", identifier);
}
Updating Tenants
Update Basic Details
var command = new UpdateTenantDetailsCommand(
tenantId: currentTenant.Id,
name: "ACME Corporation Ltd", // Updated name
reference: "CUST-12345-UK" // Updated reference
);
await commandRunner.ExecuteAsync(command, cancellationToken);
What can be updated:
- ✅ Name (display name)
- ✅ Reference (your internal ID)
- ❌ Identifier (cannot change after creation, DNS/URL impact)
- ❌ Id (GUID, immutable)
Update Tenant Metadata
var metaItems = new Dictionary<string, string>
{
{ "BillingTier", "Enterprise" },
{ "MaxUsers", "500" },
{ "Region", "EU-West" },
{ "ContactEmail", "admin@acmecorp.com" }
};
var command = new UpdateTenantMetaItemsCommand(
tenantId: currentTenant.Id,
metaItems: metaItems
);
await commandRunner.ExecuteAsync(command, cancellationToken);
Access metadata:
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
var billingTier = tenant.Value.GetMetaItem("BillingTier");
var maxUsers = int.Parse(tenant.Value.GetMetaItem("MaxUsers") ?? "100");
Remove Metadata Items
var command = new RemoveTenantMetaItemsCommand(
tenantId: currentTenant.Id,
metaKeys: new List<string> { "BillingTier", "MaxUsers" }
);
await commandRunner.ExecuteAsync(command, cancellationToken);
Enabling and Disabling Tenants
Disable Tenant
var command = new DisableTenantCommand(tenantId: currentTenant.Id);
await commandRunner.ExecuteAsync(command, cancellationToken);
Effects:
- Users cannot log in
- Existing sessions may be terminated (depends on implementation)
- Data remains in database
- Admin can still access tenant for management
- Tenant appears as "Disabled" in admin UI
Use cases:
- Subscription expired
- Payment failed
- Contract ended
- Security incident
- Tenant requested service pause
Enable Tenant
var command = new EnableTenantCommand(tenantId: currentTenant.Id);
await commandRunner.ExecuteAsync(command, cancellationToken);
Effects:
- Users can log in again
- Normal operations resume
- All data and settings preserved
Check Tenant Status
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
if (tenant.Value.IsEnabled)
{
Console.WriteLine("Tenant is active");
}
else
{
Console.WriteLine("Tenant is disabled");
}
Programmatic Enforcement
public class TenantStatusMiddleware
{
private readonly RequestDelegate next;
public async Task InvokeAsync(
HttpContext context,
IMultiTenantContextAccessor accessor,
IRepository<Tenant> tenantRepo)
{
var tenantInfo = accessor.MultiTenantContext?.TenantInfo;
if (tenantInfo != null && Guid.TryParse(tenantInfo.Id, out var tenantId))
{
var tenant = await tenantRepo.FindOne(
new ByIdSpecification<Tenant>(tenantId),
context.RequestAborted);
if (tenant.HasValue && !tenant.Value.IsEnabled)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new
{
error = "Tenant is currently disabled. Please contact support."
});
return;
}
}
await next(context);
}
}
// Register after UseMultiTenant()
app.UseMultiTenant();
app.UseMiddleware<TenantStatusMiddleware>();
Feature Management
Set System Features
var command = new SetSystemFeaturesCommand(
tenantId: currentTenant.Id,
systemFeatures: new List<string>
{
"Tasks",
"Calendar",
"Reports",
"Export",
"API"
}
);
await commandRunner.ExecuteAsync(command, cancellationToken);
Replaces entire feature list. To add/remove individual features, fetch current list first:
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
var currentFeatures = tenant.Value.SystemFeatures.ToList();
// Add new feature
if (!currentFeatures.Contains("AdvancedReports"))
{
currentFeatures.Add("AdvancedReports");
var command = new SetSystemFeaturesCommand(tenantId, currentFeatures);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
Check Feature Availability
public async Task<bool> CanAccessFeature(Guid tenantId, string featureName)
{
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
if (tenant.HasNoValue)
return false;
return tenant.Value.SystemFeatures.Contains(featureName);
}
// Usage in controller
[HttpGet("advanced-reports")]
public async Task<IActionResult> GetAdvancedReports()
{
var tenantId = GetCurrentTenantId();
if (!await CanAccessFeature(tenantId, "AdvancedReports"))
{
return Forbid("Feature not available for your subscription");
}
// ... return reports
}
Feature Tiers
public static class FeatureTiers
{
public static readonly List<string> Basic = new() { "Tasks" };
public static readonly List<string> Standard = new()
{
"Tasks",
"Calendar",
"Reports"
};
public static readonly List<string> Enterprise = new()
{
"Tasks",
"Calendar",
"Reports",
"Export",
"API",
"AdvancedReports",
"CustomIntegrations"
};
}
// Set tier
var command = new SetSystemFeaturesCommand(
tenantId,
FeatureTiers.Enterprise
);
await commandRunner.ExecuteAsync(command, cancellationToken);
MFA and Device Remembrance Settings
Configure MFA per Tenant
var command = new SetTenantDeviceRemembranceSettingsCommand(
tenantId: currentTenant.Id,
useMfa: true, // Require MFA for this tenant
useDeviceRemembrance: true, // Allow "Remember this device"
deviceRemembranceDays: 30 // Trust device for 30 days
);
await commandRunner.ExecuteAsync(command, cancellationToken);
Configuration options:
| Setting | Type | Description |
|---|---|---|
useMfa | bool | Require MFA for all users in tenant |
useDeviceRemembrance | bool | Allow users to skip MFA on trusted devices |
deviceRemembranceDays | int | Days to trust a device (0 = forever, until revoked) |
Examples:
High security (financial services):
new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: true,
useDeviceRemembrance: false, // Always require MFA
deviceRemembranceDays: 0
);
Balanced (SaaS apps):
new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: true,
useDeviceRemembrance: true,
deviceRemembranceDays: 30
);
Low security (internal tools):
new SetTenantDeviceRemembranceSettingsCommand(
tenantId,
useMfa: false,
useDeviceRemembrance: false,
deviceRemembranceDays: 0
);
Read Current Settings
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
var useMfa = tenant.Value.GetMetaItem("UseMfa") == "True";
var useDeviceRemembrance = tenant.Value.GetMetaItem("UseDeviceRemembrance") == "True";
var daysStr = tenant.Value.GetMetaItem("DeviceRemembranceDays") ?? "0";
var days = int.Parse(daysStr);
Console.WriteLine($"MFA: {useMfa}, Device Remembrance: {useDeviceRemembrance} ({days} days)");
Querying Tenants
Read Models
public interface IMultitenancyReadModels : IModuleReadModels
{
IReadModelQueryable<TenantProjection> Tenants { get; }
}
// Inject in query handler
public class GetTenantsQueryHandler
{
private readonly IMultitenancyReadModels readModels;
public async Task<QueryResult<TenantListResult>> Handle(
GetTenantsQuery request,
CancellationToken cancellationToken)
{
var tenants = await readModels.Tenants
.Where(t => t.IsEnabled)
.OrderBy(t => t.Name)
.Select(t => new TenantDto
{
Id = t.Id,
Identifier = t.Identifier,
Name = t.Name,
Reference = t.Reference,
IsEnabled = t.IsEnabled,
CreatedAt = t.CreatedAt
})
.ToListAsync(cancellationToken);
return QueryResult<TenantListResult>.Succeeded(
new TenantListResult(tenants));
}
}
Common Queries
All active tenants:
var tenants = await readModels.Tenants
.Where(t => t.IsEnabled)
.ToListAsync(cancellationToken);
Search by name:
var tenants = await readModels.Tenants
.Where(t => t.Name.Contains(searchTerm))
.ToListAsync(cancellationToken);
Find by identifier:
var tenant = await readModels.Tenants
.FirstOrDefaultAsync(
t => t.Identifier == identifier,
cancellationToken);
Count tenants:
var totalTenants = await readModels.Tenants.CountAsync(cancellationToken);
var activeTenants = await readModels.Tenants
.CountAsync(t => t.IsEnabled, cancellationToken);
Pagination:
var pageSize = 20;
var page = 2;
var tenants = await readModels.Tenants
.OrderBy(t => t.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
Bulk Operations
Enable/Disable Multiple Tenants
public async Task BulkDisableTenants(
List<Guid> tenantIds,
CancellationToken cancellationToken)
{
foreach (var tenantId in tenantIds)
{
var command = new DisableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
}
Update Features Across Tenants
public async Task ApplyFeatureTierToTenants(
List<Guid> tenantIds,
List<string> features,
CancellationToken cancellationToken)
{
foreach (var tenantId in tenantIds)
{
var command = new SetSystemFeaturesCommand(tenantId, features);
await commandRunner.ExecuteAsync(command, cancellationToken);
}
}
// Usage
var enterpriseTenants = await readModels.Tenants
.Where(t => t.BillingTier == "Enterprise")
.Select(t => t.Id)
.ToListAsync(cancellationToken);
await ApplyFeatureTierToTenants(
enterpriseTenants,
FeatureTiers.Enterprise,
cancellationToken);
Deleting Tenants
PearDrop does not provide a delete tenant command by default. Deleting tenants is a destructive operation with significant implications.
Recommended Approach: Disable Instead
// Mark as disabled instead of deleting
var command = new DisableTenantCommand(tenantId);
await commandRunner.ExecuteAsync(command, cancellationToken);
// Add metadata to track deletion request
var metaCommand = new UpdateTenantMetaItemsCommand(
tenantId,
new Dictionary<string, string>
{
{ "MarkedForDeletion", DateTime.UtcNow.ToString("O") },
{ "DeletionRequestedBy", currentUserId.ToString() }
}
);
await commandRunner.ExecuteAsync(metaCommand, cancellationToken);
Hard Delete Implementation (If Required)
public async Task<Result> HardDeleteTenant(
Guid tenantId,
CancellationToken cancellationToken)
{
// 1. Delete all tenant data (order matters for foreign keys)
await DeleteTenantUsers(tenantId, cancellationToken);
await DeleteTenantTasks(tenantId, cancellationToken);
await DeleteTenantFiles(tenantId, cancellationToken);
// ... delete all tenant-scoped data
// 2. Remove tenant record
var tenant = await repository.FindOne(
new ByIdSpecification<Tenant>(tenantId),
cancellationToken);
if (tenant.HasNoValue)
return Result.Failed("Tenant not found");
repository.Remove(tenant.Value);
var result = await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// 3. If database-per-tenant, drop database
if (tenant.Value.IsolationLevel == TenantIsolationLevel.Database)
{
await DropTenantDatabase(tenant.Value.ConnectionString, cancellationToken);
}
return result;
}
Considerations before deleting:
- Data retention policies (GDPR, compliance)
- Backup/archive tenant data first
- Notify users
- Cascade delete all tenant-owned data
- Handle cross-tenant references
- Drop databases/schemas if isolated
- Update billing systems
Next Steps
- Configuration - Complete settings reference
- Tenant Resolution - How tenants are identified
- Data Isolation - Schema and database-per-tenant patterns
- Best Practices - Production deployment guidance