Skip to content

Configuration

”.NET has IOptions<T>, why do I need anything else?”

Because IOptions<T> is bound at startup from static files and environment variables. It cannot change at runtime without a redeploy. But real applications need user-specific preferences, tenant-level overrides, and admin-togglable knobs — all modifiable through an API or UI while the application is running.

Granit provides three distinct configuration mechanisms. Each has a different purpose, source, and audience. Using the wrong one leads to either unnecessary redeployments or runtime fragility.

MechanismWhenSourceModifiable at runtimeTypingScopeConsumer
Options (IOptions<T>)Startupappsettings.json, env vars, VaultNo (redeploy)Strong (C# class)ApplicationBackend
Settings (ISettingProvider)RuntimeDatabaseYes (API/UI)Weak (string)User > Tenant > Global > Config > DefaultBackend + Frontend
Module Config (IModuleConfigProvider<T>)RuntimeDepends on implRead-onlyStrong (C# record)Application / moduleFrontend mainly

Options are the standard .NET mechanism for strongly-typed startup configuration. Granit enforces a strict pattern for all module options. See Dependency Injection for the full recipe.

ASP.NET Core loads configuration in this order. Later sources override earlier ones:

appsettings.json
-> appsettings.{Environment}.json
-> Environment variables
-> CLI arguments
-> User Secrets (Development only)
-> Vault (via Granit.Vault dynamic credentials)
public sealed class VaultOptions
{
public const string SectionName = "Vault";
[Required]
public string Address { get; set; } = string.Empty;
[Required]
public string RoleId { get; set; } = string.Empty;
[Required]
public string SecretId { get; set; } = string.Empty;
public TimeSpan TokenRenewalInterval { get; set; } = TimeSpan.FromMinutes(15);
}
services
.AddOptions<VaultOptions>()
.BindConfiguration(VaultOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
  • Connection strings, endpoint URLs, timeouts
  • Feature flags that require a redeploy to change (infrastructure-level)
  • Secrets injected via environment variables or Vault
  • Anything consumed before the DI container is built (Serilog, OpenTelemetry)

Settings are runtime key-value pairs stored in the database. They support a cascading resolution model: a value set at the user level overrides the same key at the tenant level, which overrides the global level, and so on.

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

PriorityProviderScopeDescription
100UserPer-userPersonal preferences (locale, theme, page size)
200TenantPer-tenantTenant-wide defaults
300GlobalApplication-wideAdmin-defined defaults
400Configurationappsettings.jsonDeployment-time defaults
500DefaultCodeHardcoded fallback in SettingDefinition
graph LR
    U["User (100)"] -->|null?| T["Tenant (200)"]
    T -->|null?| G["Global (300)"]
    G -->|null?| C["Configuration (400)"]
    C -->|null?| D["Default (500)"]

When IsInherited = false on a SettingDefinition, each scope is independent and the cascade does not fall through.

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
});
}
}
public class ReportService(ISettingProvider settings)
{
public async Task<int> GetPageSizeAsync(CancellationToken ct)
{
// Resolves User -> Tenant -> Global -> Config -> Default
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 SetUserLocaleAsync(
string userId, string locale, CancellationToken ct)
{
await settingManager
.SetForUserAsync(userId, "Granit.PreferredCulture", locale, ct)
.ConfigureAwait(false);
}
}

Settings marked IsEncrypted = true are encrypted at rest in the database using IStringEncryptionService. The cache holds plaintext values — when Redis is used, the cache transport is encrypted separately via TLS and Granit’s cache encryption layer. This means:

  • Database backups do not contain plaintext secrets.
  • The application reads plaintext from cache without decryption overhead on every access.
  • Cache invalidation triggers re-read and re-decrypt from the database.
  • User preferences (locale, timezone, theme, page size)
  • Tenant-level configuration (SMTP server, billing thresholds)
  • Admin-togglable knobs that must change without a redeploy
  • Any key-value pair that needs per-user or per-tenant scoping

Module Config is a lightweight read-only mechanism for exposing module state to the frontend. Unlike Options (which are internal to the backend) and Settings (which are read-write), Module Config provides a typed, public-facing snapshot of a module’s current configuration.

public sealed record WebhookModuleConfigResponse(bool StorePayload);
internal sealed class WebhookModuleConfigProvider(
IOptions<WebhooksOptions> options)
: IModuleConfigProvider<WebhookModuleConfigResponse>
{
public WebhookModuleConfigResponse GetConfig() =>
new(options.Value.StorePayload);
}
Program.cs
app.MapGranitModuleConfig<WebhookModuleConfigProvider,
WebhookModuleConfigResponse>();
// GET /webhooks/config -> { "storePayload": true }
  • Frontend needs to know if a module feature is available
  • Frontend conditionally renders UI based on backend configuration
  • Read-only — if the frontend needs to change the value, use Settings instead

Use this diagram to pick the right mechanism for a new configuration value:

flowchart TD
    A[New configuration value] --> B{Needed at startup?}
    B -->|Yes| C{Secret?}
    C -->|Yes| D["Options + Vault / env var"]
    C -->|No| E["Options (appsettings.json)"]
    B -->|No| F{Modifiable at runtime?}
    F -->|Yes| G{Per-user or per-tenant?}
    G -->|Yes| H["Settings (ISettingProvider)"]
    G -->|No| I{Secret?}
    I -->|Yes| J["Settings (IsEncrypted = true)"]
    I -->|No| H
    F -->|No| K{Consumed by frontend?}
    K -->|Yes| L["Module Config (IModuleConfigProvider)"]
    K -->|No| E

QuestionOptionsSettingsModule Config
Can a user change it?NoYesNo
Can a tenant override it?NoYesNo
Requires redeploy to change?YesNoDepends on source
Strongly typed?Yes (sealed class)No (string values)Yes (sealed record)
Encrypted at rest?Via Vault / env varsIsEncrypted flagN/A
Cached?In memory (singleton)Distributed cacheIn memory (singleton)
Exposed via HTTP?NoYes (/settings/*)Yes (/{module}/config)
Typical consumerBackend servicesBackend + frontendFrontend