Skip to content

Manage application settings

Granit.Settings provides a dynamic settings system with a five-level resolution cascade (User -> Tenant -> Global -> Configuration -> Default). Settings can be changed at runtime without redeployment, optionally encrypted at rest, and cached with automatic invalidation.

  • A .NET 10 project referencing Granit.Core
  • Granit.Settings for setting definitions and resolution
  • Granit.Settings.EntityFrameworkCore for database persistence (production)
  • Granit.Encryption if you need encrypted settings
Terminal window
dotnet add package Granit.Settings
dotnet add package Granit.Settings.EntityFrameworkCore
[DependsOn(typeof(GranitSettingsModule))]
public sealed class AppModule : GranitModule { }

Uses InMemorySettingStore — suitable for tests and local development only.

Settings are declared via ISettingDefinitionProvider. Create one provider per functional module:

using Granit.Settings.Definitions;
namespace MyApp.Settings;
public sealed class AppSettingDefinitionProvider : ISettingDefinitionProvider
{
public void Define(ISettingDefinitionContext context)
{
// User-visible setting with a default value
context.Add(new SettingDefinition("App.Theme")
{
DefaultValue = "dark",
DisplayName = "Interface theme",
IsVisibleToClients = true
});
// Encrypted setting (ISO 27001), not exposed to clients
context.Add(new SettingDefinition("App.ExternalApiKey")
{
IsEncrypted = true,
IsVisibleToClients = false,
Providers = { GlobalSettingValueProvider.ProviderName }
});
// Numeric setting restricted to Global and Tenant scopes
context.Add(new SettingDefinition("App.MaxUploadMb")
{
DefaultValue = "10",
Providers =
{
GlobalSettingValueProvider.ProviderName,
TenantSettingValueProvider.ProviderName
}
});
}
}

Register the provider:

services.AddSingleton<ISettingDefinitionProvider, AppSettingDefinitionProvider>();
PropertyTypeDefaultDescription
NamestringUnique setting key
DefaultValuestring?nullFallback when no provider has a value
IsEncryptedboolfalseEncrypt at rest via IStringEncryptionService
IsVisibleToClientsboolfalseExpose via user-facing API endpoints
IsInheritedbooltrueCascade to next level when null
ProvidersIList<string>[]Allowed providers (empty = all)

Inject ISettingProvider to read settings. The cascade runs automatically:

using Granit.Settings.Services;
namespace MyApp.Services;
public sealed class ThemeService(ISettingProvider settings)
{
public async Task<string> GetThemeAsync(
CancellationToken cancellationToken)
{
var theme = await settings.GetOrNullAsync(
"App.Theme", cancellationToken);
return theme ?? "dark";
}
}

The resolution cascade:

User (U, order=100) -- per-user preference
| null ->
Tenant (T, order=200) -- tenant-wide default
| null ->
Global (G, order=300) -- application-wide
| null ->
Config (C, order=400) -- appsettings.json / environment variables
| null ->
Default(D, order=500) -- SettingDefinition.DefaultValue

The first non-null value wins. Setting IsInherited = false stops the cascade after the first applicable provider, even if it returns null.

Inject ISettingManager to create or update setting values at any scope:

using Granit.Settings.Services;
namespace MyApp.Services;
public sealed class AdminSettingsService(ISettingManager settings)
{
public Task SetGlobalThemeAsync(
string theme, CancellationToken cancellationToken) =>
settings.SetGlobalAsync("App.Theme", theme, cancellationToken);
public Task SetTenantThemeAsync(
Guid tenantId, string theme, CancellationToken cancellationToken) =>
settings.SetForTenantAsync(
tenantId, "App.Theme", theme, cancellationToken);
public Task SetUserThemeAsync(
string userId, string theme, CancellationToken cancellationToken) =>
settings.SetForUserAsync(
userId, "App.Theme", theme, cancellationToken);
}

Cache invalidation happens automatically on writes via ISettingManager.

Your application DbContext must implement ISettingsDbContext and call ConfigureSettingsModule():

using Granit.Settings.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace MyApp.Persistence;
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options)
: DbContext(options), ISettingsDbContext
{
public DbSet<SettingRecord> SettingRecords { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ConfigureSettingsModule();
modelBuilder.ApplyGranitConventions(currentTenant, dataFilter);
}
}
// Program.cs -- replaces InMemorySettingStore with EfCoreSettingStore
builder.AddGranitSettingsEfCore<AppDbContext>();
Terminal window
dotnet ef migrations add InitSettings \
--project src/MyApp \
--startup-project src/MyApp

The migration creates the core_setting_records table with a unique index on (Name, ProviderName, ProviderKey).

The Configuration provider (C, order=400) reads values from appsettings.json or environment variables. These have priority over DefaultValue but are overridden by Global, Tenant, and User values in the database:

{
"Settings": {
"App.Theme": "light",
"App.MaxUploadMb": "50"
}
}

Step 8 — Expose settings via REST endpoints (optional)

Section titled “Step 8 — Expose settings via REST endpoints (optional)”

Granit.Settings.Endpoints provides Minimal API endpoints for user preferences and admin management:

// User preferences (authenticated, IsVisibleToClients = true only)
app.MapGranitUserSettings();
// Global admin (requires Settings.Global.Read/Manage permissions)
app.MapGranitGlobalSettings();
// Tenant admin (requires Settings.Tenant.Read/Manage permissions)
app.MapGranitTenantSettings();
ScopeGETPUTDELETE
UserGET /settings/userPUT /settings/user/{name}DELETE /settings/user/{name}
GlobalGET /settings/globalPUT /settings/global/{name}
TenantGET /settings/tenantPUT /settings/tenant/{name}

Deleting a user-level setting causes the cascade to fall through to Tenant, then Global, then Configuration, then Default.