Skip to content

Settings, Features & Reference Data

Three complementary modules for runtime application configuration: Settings for user/tenant/global key-value pairs with cascading resolution, Features for SaaS feature flags with plan-based activation, and Reference Data for i18n lookup tables (countries, currencies, statuses).

  • DirectoryGranit.Settings/ Cascading settings engine (User, Tenant, Global, Configuration, Default)
    • Granit.Settings.EntityFrameworkCore Isolated SettingsDbContext
    • Granit.Settings.Endpoints User, Global, and Tenant setting HTTP endpoints
  • DirectoryGranit.Features/ SaaS feature management (Toggle/Numeric/Selection)
    • Granit.Features.EntityFrameworkCore Isolated FeaturesDbContext
  • DirectoryGranit.ReferenceData/ i18n reference data entity and store abstractions
    • Granit.ReferenceData.EntityFrameworkCore EF Core store + seeding
    • Granit.ReferenceData.Endpoints Generic CRUD endpoints
PackageRoleDepends on
Granit.SettingsISettingProvider, ISettingManager, cascading resolutionGranit.Caching, Granit.Encryption, Granit.Security
Granit.Settings.EntityFrameworkCoreSettingsDbContext (isolated, multi-tenant + soft-delete)Granit.Settings, Granit.Persistence
Granit.Settings.EndpointsUser/Global/Tenant HTTP endpoints, SettingsCultureMiddlewareGranit.Settings, Granit.Authorization
Granit.FeaturesIFeatureChecker, IFeatureLimitGuard, plan-based cascadeGranit.Caching, Granit.Localization
Granit.Features.EntityFrameworkCoreFeaturesDbContext (isolated)Granit.Features, Granit.Persistence
Granit.ReferenceDataIReferenceDataStoreReader/Writer<T>, ReferenceDataEntityGranit.Core
Granit.ReferenceData.EntityFrameworkCore + EndpointsEF Core store, seeding, generic CRUD endpointsGranit.ReferenceData, Granit.Persistence
graph TD
    S[Granit.Settings] --> CA[Granit.Caching]
    S --> EN[Granit.Encryption]
    S --> SE[Granit.Security]
    SEF[Granit.Settings.EntityFrameworkCore] --> S
    SEF --> P[Granit.Persistence]
    SEP[Granit.Settings.Endpoints] --> S
    SEP --> A[Granit.Authorization]

    F[Granit.Features] --> CA
    F --> L[Granit.Localization]
    FEF[Granit.Features.EntityFrameworkCore] --> F
    FEF --> P

    RD[Granit.ReferenceData] --> CO[Granit.Core]
    RDEF[Granit.ReferenceData.EntityFrameworkCore] --> RD
    RDEF --> P
    RDEP[Granit.ReferenceData.Endpoints] --> RD

[DependsOn(
typeof(GranitSettingsModule),
typeof(GranitSettingsEntityFrameworkCoreModule))]
public class AppModule : GranitModule { }
Program.cs
builder.AddGranitSettingsEntityFrameworkCore(opt =>
opt.UseNpgsql(connectionString));

Implement ISettingDefinitionProvider in any module. Providers are auto-discovered at startup.

public sealed class AcmeSettingDefinitionProvider : ISettingDefinitionProvider
{
public void Define(ISettingDefinitionContext context)
{
context.Add(new SettingDefinition("Acme.DefaultPageSize")
{
DefaultValue = "25",
IsVisibleToClients = true,
IsInherited = true,
Description = "Default number of items per page"
});
context.Add(new SettingDefinition("Acme.SmtpPassword")
{
DefaultValue = null,
IsEncrypted = true,
IsVisibleToClients = false,
Providers = { "G" } // Global scope only
});
}
}
PropertyDefaultDescription
Name(required)Unique setting key
DefaultValuenullFallback when no provider supplies a value
IsEncryptedfalseEncrypt at rest via IStringEncryptionService
IsVisibleToClientsfalseExpose via user-scoped API endpoints
IsInheritedtrueLower-priority scopes inherit from higher-priority ones
Providers[] (all)Allow-list of provider names ("U", "T", "G")

Settings are resolved through a provider chain. The first provider that returns a non-null value wins:

User (U) → Tenant (T) → Global (G) → Configuration (appsettings.json) → Default (code)

When IsInherited = false, each scope is independent and does not fall through.

graph LR
    U[User] -->|null?| T[Tenant]
    T -->|null?| G[Global]
    G -->|null?| C[Configuration]
    C -->|null?| D[Default]
    style U fill:#4CAF50,color:white
    style D fill:#9E9E9E,color:white
public class ReportService(ISettingProvider settings)
{
public async Task<int> GetPageSizeAsync(CancellationToken ct)
{
string? value = await settings
.GetOrNullAsync("Acme.DefaultPageSize", ct)
.ConfigureAwait(false);
return int.TryParse(value, out int size) ? size : 25;
}
}
public class AdminService(ISettingManager settingManager)
{
public async Task SetGlobalPageSizeAsync(int size, CancellationToken ct)
{
await settingManager
.SetGlobalAsync("Acme.DefaultPageSize", size.ToString(), ct)
.ConfigureAwait(false);
}
public async Task SetTenantThemeAsync(Guid tenantId, string theme, CancellationToken ct)
{
await settingManager
.SetForTenantAsync(tenantId, "Acme.Theme", theme, ct)
.ConfigureAwait(false);
}
public async Task SetUserLocaleAsync(string userId, string locale, CancellationToken ct)
{
await settingManager
.SetForUserAsync(userId, "Granit.PreferredCulture", locale, ct)
.ConfigureAwait(false);
}
}
ScopeMethodRoutePermission
UserGET/settings/userAuthenticated
UserGET/settings/user/{name}Authenticated
UserPUT/settings/user/{name}Authenticated
UserDELETE/settings/user/{name}Authenticated
GlobalGET/settings/globalSettings.Global.Read
GlobalPUT/settings/global/{name}Settings.Global.Manage
TenantGET/settings/tenantSettings.Tenant.Read
TenantPUT/settings/tenant/{name}Settings.Tenant.Manage

Hydrates CultureInfo.CurrentUICulture and ICurrentTimezoneProvider from the authenticated user’s Granit.PreferredCulture and Granit.PreferredTimezone settings. Runs after authentication, before endpoint handlers. No-op for anonymous requests.

Program.cs
app.UseAuthentication();
app.UseMiddleware<SettingsCultureMiddleware>();
app.UseAuthorization();
{
"Settings": {
"CacheExpiration": "00:30:00"
}
}
PropertyDefaultDescription
CacheExpiration00:30:00Cache entry TTL for resolved setting values

[DependsOn(typeof(GranitFeaturesModule))]
public class AppModule : GranitModule { }

Uses InMemoryFeatureStore by default (no persistence).

Implement IFeatureDefinitionProvider to declare features. Features are grouped for admin UI organization.

public sealed class AcmeFeatureDefinitionProvider : FeatureDefinitionProvider
{
public override void Define(IFeatureDefinitionContext context)
{
FeatureGroupDefinition group = context.AddGroup("Acme", "Acme Application");
group.AddToggle("Acme.VideoConsultation",
defaultValue: "false",
displayName: "Video Consultation");
group.AddNumeric("Acme.MaxUsersCount",
defaultValue: "50",
displayName: "Maximum Users",
numericConstraint: new NumericConstraint(Min: 1, Max: 10_000));
group.AddSelection("Acme.StorageTier",
defaultValue: "standard",
displayName: "Storage Tier",
selectionValues: new SelectionValues("standard", "premium", "enterprise"));
}
}
PropertyDescription
NameUnique feature name (convention: Module.FeatureName)
DefaultValueFallback string value
ValueTypeToggle, Numeric, or Selection
NumericConstraintMin/Max bounds for Numeric features
SelectionValuesAllowed values for Selection features

Features are resolved through a Tenant → Plan → Default cascade:

Tenant override → Plan value → Default (code)

The application must implement IPlanIdProvider and IPlanFeatureStore to activate plan-level resolution. Without these, features fall back to their default values.

The main abstraction for querying feature state at runtime:

public class ConsultationService(IFeatureChecker features)
{
public async Task StartAsync(CancellationToken ct)
{
// Gate: throws FeatureNotEnabledException (HTTP 403) if disabled
await features
.RequireEnabledAsync("Acme.VideoConsultation", ct)
.ConfigureAwait(false);
// Conditional logic
bool isEnabled = await features
.IsEnabledAsync("Acme.VideoConsultation", ct)
.ConfigureAwait(false);
// Numeric value
long maxUsers = await features
.GetNumericAsync("Acme.MaxUsersCount", ct)
.ConfigureAwait(false);
}
}

Enforces numeric feature limits before mutating operations. Throws FeatureLimitExceededException (HTTP 403) when the limit is reached.

public sealed class CreatePatientHandler(
IFeatureLimitGuard limitGuard,
IPatientRepository patients)
{
public async Task HandleAsync(CreatePatientCommand cmd, CancellationToken ct)
{
long current = await patients.CountAsync(ct).ConfigureAwait(false);
await limitGuard
.CheckAsync("Acme.MaxUsersCount", current, ct)
.ConfigureAwait(false);
// ... proceed with creation
}
}
app.MapPost("/consultations", handler)
.RequiresFeature("Acme.VideoConsultation");

When the feature is disabled, FeatureNotEnabledException is thrown and mapped to HTTP 403 with errorCode: "Features:NotEnabled".

public interface IFeatureStoreReader
{
Task<string?> GetOrNullAsync(
string featureName, string? tenantId,
CancellationToken cancellationToken = default);
}
public interface IFeatureStoreWriter
{
Task SetAsync(
string featureName, string? tenantId, string value,
CancellationToken cancellationToken = default);
Task DeleteAsync(
string featureName, string? tenantId,
CancellationToken cancellationToken = default);
}

ReferenceDataEntity is an abstract base class for i18n lookup tables (countries, currencies, document types). Each entity has a unique Code, 14 language labels, and automatic label resolution based on CurrentUICulture.

public abstract class ReferenceDataEntity : AuditedEntity, IActive
{
public string Code { get; set; }
// 14 language labels
public string LabelEn { get; set; }
public string LabelFr { get; set; }
public string LabelNl { get; set; }
public string LabelDe { get; set; }
public string LabelEs { get; set; }
public string LabelIt { get; set; }
public string LabelPt { get; set; }
public string LabelZh { get; set; }
public string LabelJa { get; set; }
public string LabelPl { get; set; }
public string LabelTr { get; set; }
public string LabelKo { get; set; }
public string LabelSv { get; set; }
public string LabelCs { get; set; }
// Resolved at runtime via CurrentUICulture (not mapped to DB)
public virtual string Label { get; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
public DateTimeOffset? ValidFrom { get; set; }
public DateTimeOffset? ValidTo { get; set; }
}

The Label property resolves the appropriate label based on CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, falling back to LabelEn when the translation is empty. Regional variants use the base language fallback (fr-CA uses LabelFr, en-GB uses LabelEn, pt-BR uses LabelPt).

public sealed class Country : ReferenceDataEntity { }
public interface IReferenceDataStoreReader<TEntity>
where TEntity : ReferenceDataEntity
{
Task<PagedResult<TEntity>> GetAllAsync(
ReferenceDataQuery? query = null,
CancellationToken cancellationToken = default);
Task<TEntity?> GetByCodeAsync(
string code,
CancellationToken cancellationToken = default);
}
public interface IReferenceDataStoreWriter<in TEntity>
where TEntity : ReferenceDataEntity
{
Task CreateAsync(TEntity entity, CancellationToken cancellationToken = default);
Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default);
Task SetActiveAsync(string code, bool isActive, CancellationToken cancellationToken = default);
}

Reference data stores use manual registration per entity type, binding to the host application’s DbContext:

// Program.cs or module ConfigureServices
services.AddReferenceDataStore<Country, AppDbContext>();
services.AddReferenceDataStore<Currency, AppDbContext>();

This registers IReferenceDataStoreReader<Country>, IReferenceDataStoreWriter<Country>, and a IDataSeedContributor bridge for seeding.

Program.cs
app.MapReferenceDataEndpoints<Country>();
app.MapReferenceDataEndpoints<Currency>(opts =>
opts.AdminPolicyName = "Custom.Policy");

The entity type name is converted to a kebab-case route segment:

MethodRouteAccess
GET/reference-data/countryAuthenticated
GET/reference-data/country/{code}Authenticated
POST/reference-data/countryAdmin policy
PUT/reference-data/country/{code}Admin policy
DELETE/reference-data/country/{code}Admin policy

Implement IReferenceDataSeeder<TEntity> and register it. The IDataSeedContributor bridge invokes seeders during application startup.

public sealed class CountrySeeder : IReferenceDataSeeder<Country>
{
public IEnumerable<Country> GetSeedData()
{
yield return new Country
{
Code = "BE",
LabelEn = "Belgium",
LabelFr = "Belgique",
LabelNl = "Belgie",
LabelDe = "Belgien",
SortOrder = 1
};
yield return new Country
{
Code = "FR",
LabelEn = "France",
LabelFr = "France",
LabelNl = "Frankrijk",
LabelDe = "Frankreich",
SortOrder = 2
};
}
}

CategoryKey typesPackage
Settings modulesGranitSettingsModule, GranitSettingsEntityFrameworkCoreModule, GranitSettingsEndpointsModule
Settings abstractionsISettingProvider (GetOrNullAsync, GetAllAsync), ISettingManager (SetGlobalAsync, SetForTenantAsync, SetForUserAsync)Granit.Settings
Settings definitionsSettingDefinition, ISettingDefinitionProvider, SettingDefinitionManagerGranit.Settings
Settings optionsSettingsOptions (section "Settings", CacheExpiration)Granit.Settings
Settings endpointsMapGranitUserSettings(), MapGranitGlobalSettings(), MapGranitTenantSettings(), SettingsCultureMiddlewareGranit.Settings.Endpoints
Features modulesGranitFeaturesModule, GranitFeaturesEntityFrameworkCoreModule
Features abstractionsIFeatureChecker, IFeatureLimitGuard, IFeatureStoreReader, IFeatureStoreWriterGranit.Features
Features definitionsFeatureDefinition, FeatureDefinitionProvider, FeatureValueType, NumericConstraint, SelectionValuesGranit.Features
Features gating[RequiresFeature], .RequiresFeature(), RequiresFeatureMiddlewareGranit.Features
Reference dataReferenceDataEntity, IReferenceDataStoreReader<T>, IReferenceDataStoreWriter<T>Granit.ReferenceData
Reference data registrationAddReferenceDataStore<TEntity, TDbContext>(), MapReferenceDataEndpoints<TEntity>()Granit.ReferenceData.EntityFrameworkCore, Granit.ReferenceData.Endpoints