Skip to content

Settings

SimpleModule provides a settings infrastructure that lets modules declare configurable values with metadata. Settings are stored in a database and scoped to different levels (system, application, user).

Overview

The settings system has two parts:

  1. Setting definitions -- declared by modules at startup, describing what settings exist and their metadata
  2. Settings storage -- the Settings module provides persistence and retrieval through ISettingsContracts

Defining Settings

Override ConfigureSettings in your module class to declare settings:

csharp
[Module("Settings", RoutePrefix = "/settings", ViewPrefix = "/settings")]
public class SettingsModule : IModule
{
    public void ConfigureSettings(ISettingsBuilder settings)
    {
        settings
            .Add(new SettingDefinition
            {
                Key = "app.title",
                DisplayName = "Application Title",
                Group = "General",
                Scope = SettingScope.Application,
                DefaultValue = "SimpleModule",
                Type = SettingType.Text,
            })
            .Add(new SettingDefinition
            {
                Key = "app.theme",
                DisplayName = "Theme",
                Description = "Default color theme for the application",
                Group = "Appearance",
                Scope = SettingScope.User,
                DefaultValue = "light",
                Type = SettingType.Text,
            });
    }
}

ISettingsBuilder

The builder collects definitions from all modules:

csharp
public interface ISettingsBuilder
{
    ISettingsBuilder Add(SettingDefinition definition);
}

Calls can be chained since Add returns the builder.

SettingDefinition

Each setting is described by a SettingDefinition:

PropertyTypeDescription
KeystringUnique identifier (convention: module.name)
DisplayNamestringHuman-readable label for admin UI
Descriptionstring?Optional help text
Groupstring?Groups related settings in the UI
ScopeSettingScopeWhere the setting applies
DefaultValuestring?Default value stored as a raw string (e.g. "90", "true", "SimpleModule") -- it does not need to be JSON-encoded
TypeSettingTypeValue type -- drives the input control and validation
AllowedValuesIReadOnlyList<string>?Permitted values for a Select setting
Min / Maxdouble?Inclusive bounds for a Number setting
Patternstring?Regex applied to Text / Password / MultilineText values
RequiredboolWhen true, an empty value is rejected
SensitiveboolMasks the value in API responses (see Sensitive Settings)
OrderintSort order within a group in the admin UI
Placeholderstring?Placeholder text for the input control

SettingScope

Settings are scoped to control who can change them and where they apply:

csharp
public enum SettingScope
{
    System = 0,      // Infrastructure settings (maintenance mode, feature flags)
    Application = 1, // App-wide settings (timezone, title)
    User = 2,        // Per-user preferences (theme, language)
}
  • System -- only administrators can modify; affects the entire system
  • Application -- app-wide configuration visible to all users
  • User -- per-user preferences that override application defaults

SettingType

The type determines the input control rendered in the admin UI and the validation applied when a value is saved:

csharp
public enum SettingType
{
    Text = 0,          // Single-line text input
    Number = 1,        // Numeric input (honours Min / Max)
    Bool = 2,          // Toggle switch
    Json = 3,          // JSON editor (wide row)
    Select = 4,        // Dropdown bound to AllowedValues
    Color = 5,         // Hex colour picker (#RRGGBB)
    Url = 6,           // Absolute http/https URL
    Email = 7,         // Email address
    Password = 8,      // Masked input; usually paired with Sensitive = true
    MultilineText = 9, // Textarea (wide row)
    DateTime = 10,     // ISO 8601 date-time
}

Field Types and Validation

Each type maps to a dedicated input component on the frontend and a server-side rule in SettingValidator. Values are validated both when written through the API and before persistence:

TypeRenders asValidation
TextText inputRequired, optional Pattern
NumberNumber inputMust parse as a number; honours Min / Max
BoolToggle switchMust be true / false
JsonJSON editorMust be valid JSON
SelectDropdownMust be one of AllowedValues
ColorColour pickerMust match #RRGGBB
UrlURL inputMust be an absolute http/https URL
EmailEmail inputMust be a valid email address
PasswordMasked inputRequired, optional Pattern; set Sensitive = true to mask in responses
MultilineTextTextareaRequired, optional Pattern
DateTimeDate-time inputMust parse as an ISO 8601 date-time

Email, Url, Color, and DateTime apply their own format check and ignore Pattern to avoid duplicate errors. When a value fails validation, write operations return 422 Unprocessable Entity with the errors keyed by setting key (the service throws SettingValidationException, which carries the Key and a list of Errors).

csharp
// A Select with a fixed set of choices
.Add(new SettingDefinition
{
    Key = "app.theme",
    DisplayName = "Theme",
    Group = "Appearance",
    Scope = SettingScope.User,
    Type = SettingType.Select,
    AllowedValues = ["light", "dark", "system"],
    DefaultValue = "system",
})
// A bounded Number
.Add(new SettingDefinition
{
    Key = "auditlogs.retention.days",
    DisplayName = "Retention Days",
    Group = "Audit Logs",
    Scope = SettingScope.System,
    Type = SettingType.Number,
    Min = 1,
    Max = 3650,
    DefaultValue = "90",
})
// A sensitive secret
.Add(new SettingDefinition
{
    Key = "email.smtp.password",
    DisplayName = "SMTP Password",
    Group = "Email",
    Scope = SettingScope.System,
    Type = SettingType.Password,
    Sensitive = true,
});

Sensitive Settings

Mark secrets with Sensitive = true. Their stored value is never returned by the list/get endpoints -- the DTO's Value comes back as null and the UI shows a "set" placeholder rather than the secret. To read the actual value at runtime, use the typed contract (GetSettingAsync<T>) or the resolved endpoint from server code; do not round-trip secrets through the browser.

Real-World Example

The AuditLogs module demonstrates a comprehensive settings setup with multiple related settings:

csharp
public void ConfigureSettings(ISettingsBuilder settings)
{
    settings
        .Add(new SettingDefinition
        {
            Key = "auditlogs.capture.http",
            DisplayName = "HTTP Request Capture",
            Description = "Capture all HTTP requests in audit log",
            Group = "Audit Logs",
            Scope = SettingScope.System,
            DefaultValue = "true",
            Type = SettingType.Bool,
        })
        .Add(new SettingDefinition
        {
            Key = "auditlogs.retention.days",
            DisplayName = "Retention Days",
            Description = "Number of days to keep audit entries",
            Group = "Audit Logs",
            Scope = SettingScope.System,
            DefaultValue = "90",
            Type = SettingType.Number,
        })
        .Add(new SettingDefinition
        {
            Key = "auditlogs.excluded.paths",
            DisplayName = "Excluded Paths",
            Description = "Comma-separated path prefixes to skip",
            Group = "Audit Logs",
            Scope = SettingScope.System,
            DefaultValue = "/health,/metrics,/_content,/js/,/css/",
            Type = SettingType.Text,
        });
}

Reading and Writing Settings

The Settings module exposes ISettingsContracts for other modules to read and write setting values:

csharp
public interface ISettingsContracts
{
    // Read a stored value as a string, or typed
    Task<string?> GetSettingAsync(string key, SettingScope scope, string? userId = null);
    Task<T?> GetSettingAsync<T>(string key, SettingScope scope, string? userId = null);

    // Resolve a user setting through the fallback chain (user -> app -> default)
    Task<string?> ResolveUserSettingAsync(string key, string userId);
    Task<JsonElement?> ResolveUserSettingElementAsync(string key, string userId);

    // Write
    Task SetSettingAsync(string key, JsonElement value, SettingScope scope, string? userId = null);
    Task SetManyAsync(IReadOnlyList<BulkSettingUpdate> updates);
    Task DeleteSettingAsync(string key, SettingScope scope, string? userId = null);
    Task ResetToDefaultAsync(string key, SettingScope scope, string? userId = null);

    // DTO-shaped reads (apply masking for sensitive settings)
    Task<IEnumerable<SettingValueDto>> GetSettingValuesAsync(SettingsFilter? filter = null);
    Task<SettingValueDto?> GetSettingValueAsync(string key, SettingScope scope, string? userId = null);
}

Values are JsonElement

SetSettingAsync takes a System.Text.Json.JsonElement, not a string, so values are stored and round-tripped as typed JSON. The DTO reads (GetSettingValuesAsync / GetSettingValueAsync) return SettingValueDto, whose Value is null for sensitive settings.

Reading Settings

csharp
public class MyService(ISettingsContracts settings)
{
    public async Task DoWorkAsync()
    {
        // Read a typed setting
        var retentionDays = await settings.GetSettingAsync<int>(
            "auditlogs.retention.days",
            SettingScope.System
        );

        // Read a string setting
        var title = await settings.GetSettingAsync(
            "app.title",
            SettingScope.Application
        );
    }
}

User Setting Resolution

ResolveUserSettingAsync implements a fallback chain: it first checks for a user-scoped value, then falls back to the application-scoped default:

csharp
// Returns user's theme if set, otherwise the app default
var theme = await settings.ResolveUserSettingAsync("app.theme", userId);

Resolved and Decoded Values

ResolveUserSettingAsync returns the resolved value as a string; ResolveUserSettingElementAsync returns it as a JsonElement (the decoded value, even for sensitive settings). The latter backs the GET /api/settings/{key}/resolved endpoint, which the UI uses to show a user the effective value (their override, or the inherited default) without exposing raw stored secrets in list responses.

csharp
// Decoded fallback value for the current user
var theme = await settings.ResolveUserSettingElementAsync("app.theme", userId);

Writing Settings

Values are passed as JsonElement:

csharp
using System.Text.Json;

JsonElement Json<T>(T value) => JsonSerializer.SerializeToElement(value);

// Set an application setting
await settings.SetSettingAsync("app.title", Json("My App"), SettingScope.Application);

// Set a user preference
await settings.SetSettingAsync("app.theme", Json("dark"), SettingScope.User, userId);

// Reset to the definition's default (same as deleting the stored value)
await settings.ResetToDefaultAsync("app.theme", SettingScope.User, userId);

// Delete a user override (reverts to application default)
await settings.DeleteSettingAsync("app.theme", SettingScope.User, userId);

Bulk Updates

SetManyAsync writes several settings in one call, validating each. User-scoped entries are rejected -- use the per-user endpoints for those.

csharp
await settings.SetManyAsync(
[
    new BulkSettingUpdate { Key = "app.title", Scope = SettingScope.Application, Value = Json("My App") },
    new BulkSettingUpdate { Key = "auditlogs.retention.days", Scope = SettingScope.System, Value = Json(30) },
]);

HTTP API

The Settings module exposes a REST surface under /api/settings. Read endpoints require authentication; write endpoints require the Settings.Update permission.

MethodRoutePurpose
GET/api/settingsList settings (filterable by scope/group), with sensitive values masked
GET/api/settings/{key}?scope=Get a single setting (the scope query param is required)
GET/api/settings/{key}/resolvedResolved, decoded value for the current user
GET/api/settings/definitionsAll setting definitions
PUT/api/settingsUpdate one system/application setting
PUT/api/settings/bulkUpdate many system/application settings
DELETE/api/settings/{key}?scope=Reset a setting to its default
GET/api/settings/meThe current user's settings with resolved fallbacks
PUT/api/settings/meUpdate one of the current user's settings
DELETE/api/settings/me/{key}Clear a user override (revert to the app default)

The single PUT/DELETE and the bulk endpoint reject User-scoped writes with 400 Bad Request -- user preferences go through the /me endpoints.

Admin and User UI

Two redesigned pages ship with the module:

  • Admin settings (/settings/manage) -- system and application settings grouped and searchable, with per-scope tabs, a bulk-edit mode that batches changes into a single save, scope/override badges, and a per-row reset to default.
  • User settings (/settings/me) -- the signed-in user's preferences, showing the effective value with an annotation for whether it is the user's override or an inherited default, plus a reset that clears the override.

Both pages render the input control for each SettingType automatically and surface validation errors returned by the API inline.

Settings Definition Registry

The ISettingsDefinitionRegistry provides read-only access to all registered setting definitions at runtime:

csharp
public interface ISettingsDefinitionRegistry
{
    IReadOnlyList<SettingDefinition> GetDefinitions(SettingScope? scope = null);
    SettingDefinition? GetDefinition(string key);
}

This is used by the admin UI to dynamically render settings forms:

csharp
app.MapGet("/api/settings/definitions", (ISettingsDefinitionRegistry registry) =>
{
    // All definitions
    var all = registry.GetDefinitions();

    // Only system-scoped definitions
    var system = registry.GetDefinitions(SettingScope.System);

    // Look up a specific setting
    var def = registry.GetDefinition("app.title");
});

Key Naming Conventions

Follow the module.category.name pattern for setting keys:

KeyModuleCategoryName
app.titleapp--title
app.themeapp--theme
auditlogs.capture.httpauditlogscapturehttp
auditlogs.retention.daysauditlogsretentiondays
system.maintenance_modesystem--maintenance_mode

TIP

Use dot-separated keys for consistency. The Group property on SettingDefinition controls visual grouping in the UI independently from the key structure.

Next Steps

Released under the MIT License.