Configuration
The problem
Section titled “The problem””.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.
The three mechanisms
Section titled “The three mechanisms”| Mechanism | When | Source | Modifiable at runtime | Typing | Scope | Consumer |
|---|---|---|---|---|---|---|
Options (IOptions<T>) | Startup | appsettings.json, env vars, Vault | No (redeploy) | Strong (C# class) | Application | Backend |
Settings (ISettingProvider) | Runtime | Database | Yes (API/UI) | Weak (string) | User > Tenant > Global > Config > Default | Backend + Frontend |
Module Config (IModuleConfigProvider<T>) | Runtime | Depends on impl | Read-only | Strong (C# record) | Application / module | Frontend mainly |
Options (IOptions<T>)
Section titled “Options (IOptions<T>)”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.
Configuration sources hierarchy
Section titled “Configuration sources hierarchy”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)Defining options
Section titled “Defining options”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);}Registering options
Section titled “Registering options”services .AddOptions<VaultOptions>() .BindConfiguration(VaultOptions.SectionName) .ValidateDataAnnotations() .ValidateOnStart();When to use Options
Section titled “When to use Options”- 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 (ISettingProvider)
Section titled “Settings (ISettingProvider)”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.
Value cascade
Section titled “Value cascade”Settings resolve through a provider chain. The first provider that returns a non-null value wins:
| Priority | Provider | Scope | Description |
|---|---|---|---|
| 100 | User | Per-user | Personal preferences (locale, theme, page size) |
| 200 | Tenant | Per-tenant | Tenant-wide defaults |
| 300 | Global | Application-wide | Admin-defined defaults |
| 400 | Configuration | appsettings.json | Deployment-time defaults |
| 500 | Default | Code | Hardcoded 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.
Defining a setting
Section titled “Defining a setting”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 }); }}Reading a setting (with cascade)
Section titled “Reading a setting (with cascade)”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; }}Writing a setting
Section titled “Writing a setting”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); }}Encrypted settings
Section titled “Encrypted settings”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.
When to use Settings
Section titled “When to use Settings”- 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 (IModuleConfigProvider<T>)
Section titled “Module Config (IModuleConfigProvider<T>)”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.
Defining a module config provider
Section titled “Defining a module config provider”public sealed record WebhookModuleConfigResponse(bool StorePayload);
internal sealed class WebhookModuleConfigProvider( IOptions<WebhooksOptions> options) : IModuleConfigProvider<WebhookModuleConfigResponse>{ public WebhookModuleConfigResponse GetConfig() => new(options.Value.StorePayload);}Exposing via HTTP
Section titled “Exposing via HTTP”app.MapGranitModuleConfig<WebhookModuleConfigProvider, WebhookModuleConfigResponse>();// GET /webhooks/config -> { "storePayload": true }When to use Module Config
Section titled “When to use Module Config”- 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
Decision diagram
Section titled “Decision diagram”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
Comparison at a glance
Section titled “Comparison at a glance”| Question | Options | Settings | Module Config |
|---|---|---|---|
| Can a user change it? | No | Yes | No |
| Can a tenant override it? | No | Yes | No |
| Requires redeploy to change? | Yes | No | Depends on source |
| Strongly typed? | Yes (sealed class) | No (string values) | Yes (sealed record) |
| Encrypted at rest? | Via Vault / env vars | IsEncrypted flag | N/A |
| Cached? | In memory (singleton) | Distributed cache | In memory (singleton) |
| Exposed via HTTP? | No | Yes (/settings/*) | Yes (/{module}/config) |
| Typical consumer | Backend services | Backend + frontend | Frontend |
See also
Section titled “See also”- Dependency Injection — Options pattern details,
ValidateOnStart,PostConfigure - Settings, Features & Reference Data — full API reference for
ISettingProvider,ISettingManager, and endpoints - Vault & Encryption — secret management for Options-level configuration
- Caching — cache layer used by Settings for value resolution