Features
Controllable platform capabilities with Boolean, Numeric, and String value types.
Features are the atomic building blocks of the Entitlements system. Each feature represents a controllable capability — a boolean toggle, a numeric quota, or a string configuration. Features have a stable system key (Name) that never changes, making them safe to reference in code.
Feature Entity
A Feature defines a controllable platform capability. The Name field is a stable system key used in code; DisplayNameEn/DisplayNameAr are user-facing labels.
| Property | Type | Description |
|---|---|---|
| Id | Guid | Primary key (auto-generated) |
| Name | string | Unique system key (e.g., 'Identity.MaxAdminsPerTenant') |
| DisplayNameEn / DisplayNameAr | string | Localized titles for display in the UI |
| Module | string | Which module owns this feature (e.g., 'Identity', 'Chat', 'ERP') |
| ValueType | enum | Boolean, Numeric, String |
| DefaultValue | string | Fallback value when no edition or override specifies one |
| Description | string? | Detailed description of what this feature controls |
| Category | string? | Grouping category (e.g., 'Security', 'Limits') |
| SortOrder | int | Display order in the feature list |
| IsVisibleInUI | bool | If false, hidden from edition management UI (internal-only) |
| IsSystem | bool | System features are read-only (seeded, cannot be deleted via API) |
| IsMarketingOnly | bool | If true, shown on pricing cards but not enforced by runtime checks |
| CreatedAt | DateTime | When the feature was created |
| UpdatedAt | DateTime? | Last modification timestamp |
public class Feature : AuditableEntity, ISoftDeletable
{
public string Name { get; set; } = string.Empty; // Unique system key
public string DisplayName { get; set; } = string.Empty; // UI label
public string? Description { get; set; }
public string? Category { get; set; } // Grouping
public FeatureValueType ValueType { get; set; } = FeatureValueType.Boolean;
public string DefaultValue { get; set; } = "false"; // Global fallback
public bool IsSystem { get; set; } // Read-only if true
public bool IsVisible { get; set; } = true;
// Validation constraints (Numeric features)
public string? MinValue { get; set; } // e.g. "0"
public string? MaxValue { get; set; } // e.g. "1000", "-1" = unlimited
public string? AllowedValues { get; set; } // e.g. "light,dark,custom"
public int SortOrder { get; set; }
}Value Types
Feature values are stored as strings but interpreted according to their ValueType. The system validates values against the expected type at creation and update time.
| Type | Stored As | Example Values | Quota Tracking | Use Case |
|---|---|---|---|---|
| Boolean | "true" / "false" | "true" | No | Toggle capabilities on/off (Chat.Enabled, SSO.Enabled) |
| Numeric | Integer string | "50", "1000", "-1" (unlimited) | Yes (QuotaCounter) | Resource limits (MaxAdmins, MaxStorage, MaxApiCalls) |
| String | Arbitrary string | "dark", "premium", "custom-logo.png" | No | Configuration values (Theme, LogoUrl, SupportTier) |
public enum FeatureValueType
{
/// <summary>On/Off toggle, stored as "true" or "false"</summary>
Boolean = 0,
/// <summary>Numeric limit, stored as integer string. -1 = unlimited</summary>
Numeric = 1,
/// <summary>Arbitrary string configuration value</summary>
String = 2,
}System vs Custom Features
SCRIPE distingscripehes between system features (seeded at startup, read-only) and custom features (created by admins via API):
| System Features | Custom Features | |
|---|---|---|
| Created By | Startup seeder (EntitlementsStartupSeeder) | Platform admin via API |
| Deletable | No (ISoftDeletable guarded) | Yes |
| Editable | Only DefaultValue and Description | Fully editable |
| Purpose | Core platform capabilities | Tenant-specific extensions |
| Examples | Chat.Enabled, MaxAdmins, SSO.Enabled | CustomReports, MaxProjects, WhiteLabel |
Feature Seeding
System features are automatically seeded at application startup by EntitlementsStartupSeeder. The seeder checks if each system feature already exists (by Name) and only creates missing ones — existing features are never overwritten.
public class EntitlementsStartupSeeder : IStartupSeeder
{
private readonly IFeatureRepository _features;
public async Task SeedAsync(CancellationToken ct)
{
var systemFeatures = new[]
{
new Feature
{
Name = "Chat.Enabled",
DisplayName = "Chat",
ValueType = FeatureValueType.Boolean,
DefaultValue = "false",
IsSystem = true,
Category = "Communication"
},
new Feature
{
Name = "MaxAdmins",
DisplayName = "Maximum Administrators",
ValueType = FeatureValueType.Numeric,
DefaultValue = "5",
MinValue = "1",
MaxValue = "-1",
IsSystem = true,
Category = "Limits"
},
new Feature
{
Name = "SSO.Enabled",
DisplayName = "Single Sign-On",
ValueType = FeatureValueType.Boolean,
DefaultValue = "false",
IsSystem = true,
Category = "Security"
},
};
foreach (var feature in systemFeatures)
{
if (!await _features.ExistsByNameAsync(feature.Name, ct))
await _features.AddAsync(feature, ct);
}
}
}Quota Tracking (QuotaCounter)
Numeric features support automatic quota enforcement via the QuotaCounter entity. The FeatureCheckBehavior checks the current usage against the resolved limit for every IRequireFeature command targeting a numeric feature.
| Property | Type | Description |
|---|---|---|
| TenantId | Guid | The tenant owning this counter |
| ResourceType | string | The feature/resource type being tracked (e.g. Identity.MaxAdminsPerTenant) |
| Used | int | Current confirmed resource count in the database |
| Reserved | int | Active slot reservations for in-flight requests |
| Max | int | Specific tenant limit resolved from plans or settings (-1 = unlimited) |
| PoolRootTenantId | Guid? | Root tenant ID if this resource is shared across sibling tenants in a pool |
| PoolMax | int | Maximum total limit allowed across the entire pool (-1 = unlimited) |
// Inside TenantQuotaService.cs:
public async Task<bool> IsQuotaAvailableAsync(
Guid tenantId,
string featureName,
int currentUsage,
CancellationToken ct = default)
{
// Concurrency Protection: acquire row-level lock on the tenant
await _tenantRepository.LockTenantForQuotaAsync(tenantId, ct);
// Resolve: 1. IFeatureChecker limit, 2. Fallback to legacy TenantSettings, 3. -1 fallback
var limit = await GetEffectiveLimitAsync(tenantId, featureName, ct);
if (limit == -1)
return true;
return currentUsage < limit;
}Feature Cache
Resolved feature values are cached in the IFeatureCache to avoid database queries on every request. The cache is invalidated whenever an edition's features change, a subscription is modified, or an override is set/removed. In microservice deployments without the Entitlements module, a NoOpFeatureCache treats all features as enabled.
IRequireFeature Pattern
To gate any CQRS command behind a feature check, simply implement the IRequireFeature marker interface. The FeatureCheckBehavior automatically intercepts the request, resolves the tenant's feature value, and rejects if disabled or over quota.
// ─── Boolean Feature Gate (IRequireFeature) ───
// Step 1: Mark your command
public class SendBulkEmailCommand : ICommand, IRequireFeature
{
public string RequiredFeatureName => "BulkEmail.Enabled";
public List<string> Recipients { get; set; } = [];
public string Subject { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
}
// Step 2: That's it! FeatureCheckBehavior handles the rest.
// If "BulkEmail.Enabled" is resolved to false → returns 403 Forbidden
// If resolved to true → command proceeds normally to the handler
// ─── Numeric Feature Quota (Handler Enforcement) ───
// For numeric features with resource limits, validate quotas in the handler:
public class CreateAdminCommandHandler : IRequestHandler<CreateAdminCommand, Result<Guid>>
{
private readonly ITenantQuotaService _quotaService;
private readonly IQuotaCounterRepository _quotaCounterRepository;
public async Task<Result<Guid>> Handle(CreateAdminCommand request, CancellationToken ct)
{
// 1. Validate quota availability (concurrency-safe via LockTenantForQuotaAsync)
var isAvailable = await _quotaService.IsQuotaAvailableAsync(request.TenantId, "Identity.MaxAdminsPerTenant", currentUsage, ct);
if (!isAvailable)
return Result.Failure(Error.Forbidden("Admin limit exceeded"));
// 2. Atomically reserve slot in repository
var reserved = await _quotaCounterRepository.TryReserveSlotAsync(request.TenantId, "Identity.MaxAdminsPerTenant", ct);
if (!reserved)
return Result.Failure(Error.Forbidden("Admin limit exceeded"));
try
{
// Provision resource...
await _quotaCounterRepository.ConfirmReservationAsync(request.TenantId, "Identity.MaxAdminsPerTenant", ct);
return Result.Success(adminId);
}
catch
{
// Rollback reservation on failure
await _quotaCounterRepository.ReleaseReservationAsync(request.TenantId, "Identity.MaxAdminsPerTenant", ct);
throw;
}
}
}API Endpoints
The Features controller exposes 5 CRUD endpoints. System features cannot be deleted:
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/features | List all features (paginated, filterable by category/type) | 🔒 Required |
| GET | /api/v1/features/{id} | Get feature details by ID | 🔒 Required |
| POST | /api/v1/features | Create a new custom feature | 🔒 Required |
| PUT | /api/v1/features/{id} | Update feature metadata (system features: DefaultValue/Description only) | 🔒 Required |
| DELETE | /api/v1/features/{id} | Soft-delete a custom feature (system features cannot be deleted) | 🔒 Required |