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.
Outside the Development environment the editor and its API answer 403 until you decide who may use them:
app.UseConfigWay()
.RequireAuthorization(policy => policy.RequireRole("Admin"));
// or, for a public demo:
app.UseConfigWay().AllowAnonymous();A fallback authorization policy counts as a decision too. An editor nobody decided on is reported in the log at startup.
The editor runs in Way.Shell, the UI shell shared by the Kododo Way libraries: the layout, theme, language and session handling come from the shell, and with other Ways in the same app it can also be opened from the shell's panel:
builder.Services.AddWayShell(o =>
{
o.Title = "Acme";
o.HomeUrl = "~/";
o.User = ctx => ctx.User.Identity?.IsAuthenticated == true ? new ShellUser(ctx.User.Identity.Name!) { SignOutUrl = "~/logout" } : null;
});
app.UseConfigWay("/config").RequireAuthorization("Admin");
app.UseWayShell("/admin").RequireAuthorization(); // every Way the user may openUse
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


