Runtime configuration editor for ASP.NET Core. View and modify IOptions<T> values at runtime through a built-in web UI without restarting the application.
A live demo is available at kododo.dev/configway/demo.
dotnet add package Kododo.ConfigWay
dotnet add package Kododo.ConfigWay.UI
dotnet add package Kododo.ConfigWay.PostgreSQL # or Kododo.ConfigWay.SqlServer — optional, for persistencebuilder.AddConfigWay(x =>
{
x.AddOptions<EmailOptions>();
x.AddOptions<AppOptions>();
x.AddUiEditor();
x.UsePostgreSql(builder.Configuration.GetConnectionString("DefaultConnection")!);
// or: x.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!);
});
app.UseConfigWay(); // mounts UI at /config// Section name inferred from type — "Options" suffix stripped
x.AddOptions<EmailOptions>(); // -> "Email"
// Override explicitly
x.AddOptions<EmailOptions>("Mail");Nested types are supported and rendered as collapsible subsections in the UI.
ConfigWay maps C# property types to dedicated UI controls automatically:
| C# type | UI control |
|---|---|
string |
Text input |
bool |
Toggle switch |
int, long, double, decimal, ... |
Numeric input |
enum |
Dropdown select |
T[], List<T>, IList<T>, ... |
Collapsible array editor |
Nullable variants (bool?, int?, etc.) are handled the same way.
Collection properties (T[], List<T>, IList<T>, IEnumerable<T>, IReadOnlyList<T>, ICollection<T>, IReadOnlyCollection<T>) are rendered as a collapsible array editor with add and remove buttons.
Simple arrays with scalar element types (string[], int[], Severity[], ...) show one input field per item:
public class WebhooksOptions
{
[Display(Name = "Allowed Origins")]
public string[] AllowedOrigins { get; set; } = [];
}Complex arrays with class element types show a full sub-form per item, supporting nested objects and all scalar field types:
public class WebhookEndpoint
{
public string Url { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public WebhookEvent Event { get; set; }
}
public class WebhooksOptions
{
[Display(Name = "Endpoints")]
public WebhookEndpoint[] Endpoints { get; set; } = [];
}Items that come from lower configuration layers (appsettings.json, environment variables) are marked as non-deletable. They can be edited but not removed, because deleting them from the ConfigWay store would not suppress the underlying value.
Every field, section and array exposes a reset button that appears when the current value differs from the value in the underlying configuration layers (appsettings.json, environment variables, etc.). Resetting removes the ConfigWay-stored override so the original value from those lower layers takes effect again, without restarting the application.
| Scope | Behaviour |
|---|---|
| Field | Removes the single key from the ConfigWay store. The reset button is shown only when the field differs from its base-config value. |
| Section | Resets all fields and arrays inside the section recursively. Items added via ConfigWay are removed; non-deletable items are reset to their base-config values. |
| Array | Items added via ConfigWay are removed. Non-deletable items (those that exist in lower config layers) have their values reset to the base-config value. |
Pending resets are batched with any other edits and applied together when Save is pressed. Pressing Discard also discards pending resets.
Mark any string property with [DataType(DataType.Password)] from System.ComponentModel.DataAnnotations to treat it as a sensitive field:
using System.ComponentModel.DataAnnotations;
public class SmtpCredentials
{
public string Username { get; set; } = string.Empty;
[DataType(DataType.Password)]
public string Password { get; set; } = string.Empty;
}Sensitive fields behave as follows:
- The input renders as a password field (
*****). A placeholder indicates whether a value is currently stored. - The real value is never returned by the API. When a value is stored, the API returns
"***". When nothing is stored, it returnsnull. - Submitting an empty value is treated as "no change". To remove a stored sensitive value, use the reset button.
- The reset button appears when a sensitive value is stored. Resetting deletes the stored value and lets the underlying configuration layer (appsettings.json, environment variable) take effect again without a restart.
- Sensitive values are masked in the UI and the API only. The store keeps them as plain text, so restrict access to the database accordingly.
Use [Display] from System.ComponentModel.DataAnnotations to control how options appear in the UI.
using System.ComponentModel.DataAnnotations;
[Display(Name = "E-mail", Description = "Outgoing mail server settings")]
public class EmailOptions
{
[Display(Name = "SMTP Server", Description = "Hostname of the mail server, e.g. smtp.gmail.com")]
public string SmtpServer { get; set; } = string.Empty;
[Display(Name = "Sender address")]
public string SenderEmail { get; set; } = string.Empty;
[Display(Name = "Credentials")]
public Credentials Credentials { get; set; } = new();
}Name overrides the label shown next to the field or in the section header. When omitted, the property name is used as-is.
Description renders a small info icon next to the label. Hovering over it shows the description as a tooltip. Works on both fields and sections.
[Display] on enum members controls the label shown in the dropdown. The underlying member name is still used as the stored value.
public enum StorageProvider
{
[Display(Name = "Amazon S3")]
S3,
[Display(Name = "Azure Blob Storage")]
AzureBlob,
[Display(Name = "Google Cloud Storage")]
Gcs,
}// Data annotations
builder.Services.AddOptions<EmailOptions>().ValidateDataAnnotations();
// Custom validator
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();Errors are shown in the UI and saving is blocked until they are resolved.
The /config route is not protected by default:
app.UseConfigWay()
.RequireAuthorization(policy => policy.RequireRole("Admin"));Use
IOptionsSnapshot<T>orIOptionsMonitor<T>in your code to always get the latest values after a reload.
- Changes are reloaded only in the instance that saved them. When the app runs as several replicas, the other replicas pick up the new values after a restart.
- Saves do not check for concurrent edits. If two people edit the same section at the same time, the last save wins.
You can replace the built-in in-memory store with any backend by implementing IStore from Kododo.ConfigWay.Core:
builder.AddConfigWay(x =>
{
x.Store = new MyCustomStore(); // implements IStore
x.AddOptions<AppOptions>();
});MIT


