Skip to content

Repository files navigation

Configuration.Writable

NuGet Version GitHub Actions Testing Status GitHub Actions Release Status

Yet another configuration library with features including: type-safe operations, change detection, version migration, validation, and more.

Features

Quick Start

Save below code to example.cs and run it with dotnet run example.cs (requires .NET 10 or later).

#!/usr/bin/env dotnet
#:package Configuration.Writable@*

using System.Text.Json.Serialization;
using Configuration.Writable;
using Configuration.Writable.FormatProvider;

// initialize
WritableOptions.Initialize(conf => {
    conf.FormatProvider = new JsonAotFormatProvider(SampleSettingSerializerContext.Default);
    conf.Add<SampleSetting>(c => {
        c.UseFile("usersettings.json");
    });
});

// get the writable options instance
var options = WritableOptions.GetOptions<SampleSetting>();

// get values
Console.WriteLine($"Current Name: {options.CurrentValue.Name}");
// optionally, you can register change callback
options.OnChange(newSetting => {
    Console.WriteLine($">> Settings changed! Name: {newSetting.Name}");
});

// and save to storage
Console.Write("Enter new name: ");
var newName = Console.ReadLine() ?? "";
await options.SaveAsync(setting => {
    setting.Name = newName;
});

// announce saved location
Console.WriteLine($"Saved to {options.ConfigurationInfo.WritePath}");

// need some delay to see the change callback in action
await Task.Delay(100);

// ------
// setting class
[OptionsModel(Id = "SampleSetting", Version = 1)]
public partial class SampleSetting
{
    public string Name { get; set; } = "default name";
}

// source generation context for System.Text.Json serialization
[JsonSerializable(typeof(SampleSetting))]
public partial class SampleSettingSerializerContext : JsonSerializerContext;

The output will be as follows:

Current Name: default name
Enter new name: Alice
Saved to /path/to/your/current/directory/usersettings.json
>> Settings changed! Name: Alice

Usage

Setup

Install Configuration.Writable from NuGet.

dotnet add package Configuration.Writable

Then, prepare a class (UserSetting) in advance that you want to read and write as settings.

using Configuration.Writable;

// Add [OptionsModel] to the class and mark it as partial class.
[OptionsModel(Id = "UserSetting", Version = 1)]
public partial class UserSetting
{
    // default value can be specified
    public string Name { get; set; } = "default name";
    public int Age { get; set; } = 20;
}

[!TIPS] Be sure to add partial. This allows you to take advantage of various features provided by the Source Generator.

Simple Application (Without DI)

If you are not using DI (for example, in WinForms, WPF, console apps, etc.), Use WritableOptions as the starting point for reading and writing settings.

using Configuration.Writable;

// initialize once (at application startup)
WritableOptions.Initialize(conf => {
    conf.Add<SampleSetting>();
});

// -------------
// get the writable config instance with the specified setting class
var options = WritableOptions.GetOptions<SampleSetting>();

// get the UserSetting instance
var sampleSetting = options.CurrentValue;
Console.WriteLine($">> Name: {sampleSetting.Name}");

// and save to storage
await options.SaveAsync(setting => {
    setting.Name = "new name";
});
// By default, it's saved to ./usersettings.json

Important

Always register the type with WritableOptions.Initialize before calling WritableOptions.GetOptions.

Host Application (With DI)

If you are using DI (for example, in ASP.NET Core, Blazor, Worker Service, etc.), register IReadOnlyOptions<T> and IWritableOptions<T> in the DI container. First, call AddWritableOptionsto register the settings class.

// Program.cs
builder.Services.AddWritableOptions(conf => {
    conf.Add<UserSetting>();
});

Then, inject IReadOnlyOptions<T> or IWritableOptions<T> to read and write settings.

// read config in your class
// you can also use IOptions<T>, IOptionsMonitor<T> or IOptionsSnapshot<T>
public class ConfigReadService(IReadOnlyOptions<UserSetting> options) {
    public void Print() {
        // get the UserSetting instance
        var sampleSetting = options.CurrentValue;
        Console.WriteLine($">> Name: {sampleSetting.Name}");
    }
}

// read and write config in your class
public class ConfigReadWriteService(IWritableOptions<UserSetting> options) {
    public async Task UpdateAsync() {
        // get the UserSetting instance
        var sampleSetting = options.CurrentValue;
        // and save to storage
        await options.SaveAsync(setting => {
            setting.Name = "new name";
        });
    }
}

ASP.NET Core (With DI)

By explicitly specifying the SectionName, you can dynamically update existing configuration files such as appsettings.json.

var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddWritableOptions(conf => {
    conf.UseFile("appsettings.json");
    conf.Add<UserSetting>(c => c.SectionName = "MySetting");
});

// In this case, the settings will be saved under the `MySetting` section in `appsettings.json`.

Reading and writing settings is performed in the same way as described above in Host Application.

Customization

Configuration Method

You can configure common settings once and register multiple types in the same block. The block is fully collected before registration, so common settings may appear before or after Add<T>. Type-specific configuration takes precedence.

// Without DI
WritableOptions.Initialize(conf => {
    // 1. common configuration here
    conf.Add<SampleSetting>(c => {
        // 2. specific configuration for SampleSetting
    });
});

// With DI
builder.Services.AddWritableOptions(conf => {
    // 1. common configuration here
    conf.Add<UserSetting>(c => {
        // 2. specific configuration for UserSetting
    });
});

Under the following, the parts written as builder.Services.AddWritableOptions(...) can be read as WritableOptions.Initialize(...).

Save Location

Default behavior is to save to {AppContext.BaseDirectory}/usersettings.json (in general, the same directory as the executable). If you want to change the save location, use conf.UseFile(path) or conf.UseXxxDirectory().AddFilePath(path).

For example:

conf.Add<UserSetting>(c => {
    // to save to the parent directory
    c.UseFile("../myconfig");
    // alternatively, to save to a child directory
    // c.UseFile("config/myconfig");
});

// to save to a common settings directory
//   in Windows: %APPDATA%/MyAppId
//   in macOS: $XDG_CONFIG_HOME/MyAppId or ~/Library/Application Support/MyAppId
//   in Linux: $XDG_CONFIG_HOME/MyAppId or ~/.config/MyAppId
conf.UseStandardSaveDirectory("MyAppId");
conf.Add<UserSetting>(c => {
    // -- and specific configuration for UserSetting
    // In this case, it will be saved as MyAppId/mysettings.json.
    c.AddFilePath("mysettings");
});
Available base directories

You can following methods to specify the base directory:

  • UseExecutableDirectory(): directory where the executable is located (AppContext.BaseDirectory).
  • UseCurrentDirectory(): current working directory.
  • UseSpecialFolder(folder): special folder specified by Environment.SpecialFolder.
  • UseCustomDirectory(path): custom directory specified by path.
  • UseStandardSaveDirectory(appId): standard application data directory.
    • in Windows: %APPDATA%/appId
    • in macOS: $XDG_CONFIG_HOME/appId or ~/Library/Application Support/appId
    • in Linux: $XDG_CONFIG_HOME/appId or ~/.config/appId
Priority Determination Details

When multiple locations are specified, the load/save destination is determined on initialization on the following priority order:

  1. Explicit priority (descending)
  2. Target file already exists and able to open with write access
  3. Target directory already exists and able to create file
  4. Order of registration (earlier registrations have higher priority)
conf.UseCustomDirectory(@"D:\SpecialFolder\")
    .AddFilePath("first");        // is not existing folder/file yet
conf.UseStandardSaveDirectory("MyAppId")
    .AddFilePath("second", priority: 10);
conf.UseExecutableDirectory()
    .AddFilePath("third")         // is already exist directory but not file
    .AddFilePath("child/fourth"); // is already exist file

// In this case, the priorities are as follows:
// 1: %APPDATA%/MyAppId/second (priority 10)
// 2: ./child/fourth (target file exists)
// 3: ./third (target directory exists)
// 4: D:\SpecialFolder\first (target directory/file does not exist)
Toggle Save Location Based on Environment

If you want to toggle between development and production environments, you can use #if RELEASE pattern or builder.Environtment.IsProduction().

// those pattern are saved to
// - development: ./mysettings.json (executable directory)
// - production:  %APPDATA%/MyAppId/mysettings.json (on Windows)

// without DI
WritableOptions.Initialize(options => {
#if DEBUG
    var isProduction = false;
#else
    var isProduction = true;
#endif
    options.UseStandardSaveDirectory("MyAppId", enabled: isProduction);
    options.Add<UserSetting>(conf => conf.AddFilePath("mysettings"));
});

// if using IHostApplicationBuilder
builder.Services.AddWritableOptions(options => {
    var isProd = builder.Environment.IsProduction();
    options.UseStandardSaveDirectory("MyAppId", enabled: isProd);
    options.Add<UserSetting>(conf => conf.AddFilePath("mysettings"));
});

FormatProvider

By default, files are saved in JSON format. If you want to customize the format, specify conf.FormatProvider as follows.

using Configuration.Writable.FormatProvider;

// use Json format with indentation
conf.FormatProvider = new JsonFormatProvider() {
    JsonSerializerOptions = new () {
        // you can customize JsonSerializerOptions as needed
        WriteIndented = true
    },
};

// if you want to use source generation for JSON serialization, use JsonAotFormatProvider.
conf.FormatProvider = new JsonAotFormatProvider(SampleSettingSerializerContext.Default);

// ------
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SampleSetting))]
public partial class SampleSettingSerializerContext : JsonSerializerContext;

If you want to save in other formats, install the required packages and specify the corresponding provider. Currently, the following providers are available:

Provider Description NuGet Package NativeAOT
JsonFormatProvider save in JSON format. Built-in ✅️
XmlFormatProvider save in XML format. NuGet Version ❌️
YamlFormatProvider save in YAML format. NuGet Version ✅️

YAML

To read and write YAML, you need to add [YamlObject] to your settings class.

[OptionsModel(Id = "SampleSetting", Version = 1), YamlObject] // add [YamlObject] and mark as partial class
public partial class SampleSetting
{
    public string Name { get; set; } = "";
    public DateTime LastUpdatedAt { get; set; }
}

and following code to configure the YAML format provider.

// 1. Register formatters at startup (required for NativeAOT)
SampleSetting.__RegisterVYamlFormatter();
// 2. Congigure YamlFormatProvider
builder.Services.AddWritableOptions(conf => {
    conf.FormatProvider = new YamlFormatProvider();
    conf.Add<SampleSetting>();
});

For more details, please refer to the Example.ConsoleApp.Yaml project.

FileProvider

Default FileProvider (CommonFileProvider) supports the following features:

  • Automatically retry when file access fails (default is max 3 times, wait 100ms each)
  • Create 1 hidden backup by default; configure BackupMaxCount = 0 to disable backups
  • Atomic file writing (write to a temporary file first, then rename it)
  • Thread-safe: uses internal semaphore to ensure safe concurrent access

If you want to change the way files are written, create a class that implements IWritableFileProvider and specify it in conf.FileProvider.

using Configuration.Writable.FileProvider;

conf.FileProvider = new CommonFileProvider() {
    // retry up to 5 times when file access fails
    MaxRetryCount = 5,
    // wait 100ms, 200ms, 300ms, ... before each retry
    RetryDelay = (attempt) => 100 * attempt,
    // keep 5 backup files when saving
    BackupMaxCount = 5,
    // use a custom backup directory; "/" saves beside the configuration file
    BackupDirectory = "my-backups",
};

Change Detection

You can automatically detect changes to the file and use the latest settings.
For example:

public class MyService(IWritableOptions<UserSetting> options) : IDisposable
{
    public void WatchStart() {
        // register change callback
        _disposable = options.OnChange(newSetting => {
            // called when the configuration file is changed externally
            Console.WriteLine($">> Settings changed: Name={newSetting.Name}, Age={newSetting.Age}");
        });
        // you can also register a callback for reload failures
        // options.OnReloadFailed(ex => {
        //     Console.WriteLine($">> Settings reload failed: {ex.Message}");
        // });
    }

    public async Task UpdateAsync() {
        // get the UserSetting instance
        var sampleSetting = options.CurrentValue;
        // and save to storage
        await options.SaveAsync(setting => {
            setting.Name = "new name";
        });
        // this will trigger the OnChange callback
    }

    // on Dispose, unregister the change callback
    public void Dispose() => _disposable?.Dispose();

    private IDisposable? _disposable;
}

By default, debouncing is enabled to coalesce high-frequency file changes. Notifications are delayed until changes have stopped for 300ms. If you want to change the debounce duration, use conf.OnChangeDebounce.

conf.OnChangeDebounce = TimeSpan.FromMilliseconds(500); // customize to 500ms
conf.OnChangeDebounce = TimeSpan.Zero;                  // disable debouncing

Logging

Logging is enabled by default in DI environments.
If you are not using DI, or if you want to override the logging settings, you can enable logging by specifying conf.Logger.

// without DI
conf.Logger = LoggerFactory
    // enable console logging
    .Create(builder => builder.AddSimpleConsole())
    .CreateLogger("Configuration.Writable");

// with DI
// no setup required (uses the logger from DI)

When the output level is set to Information, mainly the following two logs are output.

info: Configuration.Writable[0]
      Configuration file change detected: mysettings.json (Renamed)
info: Configuration.Writable[0]
      Configuration saved successfully to mysettings.json

SectionName

When saving settings, they are written to a configuration file in a structured format. By default, settings are stored directly at the root level:

{
  // properties of UserSetting are stored directly at the root level
  "Name": "custom name",
  "Age": 30
}

For example, if you want to write to appsettings.json and coexist with other settings, you can use conf.SectionName to group settings in a specific section.
To write settings to a specific section, only that section is updated while the rest remains unchanged.

// configure to save under MyAppSettings:Foo:Bar section
builder.Services.AddWritableOptions(options => {
    options.UseFile("appsettings.json");
    options.Add<UserSetting>(conf => {
        conf.SectionName = "MyAppSettings:Foo:Bar";
    });
});

// and save settings
options.SaveAsync(setting => {
    setting.Name = "custom name";
    setting.Age = 30;
});

The resulting appsettings.json will look like this:

{
  "MyAppSettings": {
    "Foo": {
      "Bar": {
        // saved under MyAppSettings:Foo:Bar section
        "Name": "custom name",
        "Age": 30
      }
    }
  },
  // another settings remain unchanged
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning"
    }
  }
}

By using this method, it is possible to save multiple configuration classes in different sections within the same file.

builder.Services.AddWritableOptions(options => {
    options.UseFile("appsettings.json");
    options.Add<UserSettingA>(conf => conf.SectionName = "SettingsA");
    options.Add<UserSettingB>(conf => conf.SectionName = "SettingsB");
});
/* result may look like this:
{
  "SettingsA": {
    "Name": "custom name A", "Age": 30
  },
  "SettingsB": {
    "Name": "custom name B", "Age": 40
  }
} */

For more details, please refer to the Example.WebApi project.

Validation

By default, validation using DataAnnotations is enabled. If validation fails, an OptionsValidationException is thrown and the settings are not saved.

using Microsoft.Extensions.Options;

builder.Services.AddWritableOptions(options => {
    options.Add<UserSetting>(conf => {
        // if you want to disable validation of DataAnnotations, do the following:
        // conf.UseDataAnnotationsValidation = false;
    });
});

var options = WritableOptions.GetOptions<UserSetting>();
try {
    await options.SaveAsync(setting => {
        setting.Name = "ab"; // too short
        setting.Age = 200;  // out of range
    });
}
catch (OptionsValidationException ex) {
    Console.WriteLine($">> Validation failed: {ex.Message}");
    // setting is not saved if validation fails
}

internal class UserSetting
{
    [Required, MinLength(3)]
    public string Name { get; set; } = "default name";
    [Range(0, 150)]
    public int Age { get; set; } = 20;
}

To use source generators for DataAnnotations, use the following pattern.

builder.Services.AddWritableOptions(options => {
    options.Add<UserSetting>(conf => {
        // disable attributes-based validation
        conf.UseDataAnnotationsValidation = false;
        // enable source-generator-based validation
        conf.WithValidator<UserSettingValidator>();
    });
});

internal class UserSetting { /* ... */ }

[OptionsValidator]
public partial class UserSettingValidator : IValidateOptions<UserSetting>;

Alternatively, you can add custom validation using WithValidatorFunction or WithValidator.

using Microsoft.Extensions.Options;

builder.Services.AddWritableOptions(options => {
    options.Add<UserSetting>(conf => {
        // add custom validation function
        conf.WithValidatorFunction(setting => {
            if (setting.Name.Contains("invalid")) {
                return ValidateOptionsResult.Fail("Name must not contain 'invalid'.");
            }
            return ValidateOptionsResult.Success;
        });
        // or use a custom validator class
        conf.WithValidator<MyCustomValidator>();
    });
});

// IValidateOptions sample
internal class MyCustomValidator : IValidateOptions<UserSetting>
{
    public ValidateOptionsResult Validate(string? name, UserSetting options) {
        if (options.Age < 10)
            return ValidateOptionsResult.Fail("Age must be at least 10.");
        if (options.Age > 100)
            return ValidateOptionsResult.Fail("Age must be 100 or less.");
        return ValidateOptionsResult.Success;
    }
}

Note

Validation at startup is intentionally not provided. The reason is that in the case of user settings, it is preferable to prompt for correction rather than prevent startup when a validation error occurs.

Json Schema Support

Configuration.Writable can generate JSON Schema files for versioned options models and add schema references to root JSON and YAML files.

Generating JSON Schema

First, add the following configuration in your code:

conf.EnableJsonSchemaGeneration();
// For NativeAOT, you need to specify JsonSerializerContext.
conf.EnableJsonSchemaGeneration(SampleSettingSerializerContext.Default);

Then, when you build the application, a feature to generate schemas using the --cw-generate-json-schema option is embedded.

# generate JSON schema files in ./artifacts/schemas
./MyApplication.exe --cw-generate-json-schema ./artifacts/schemas
#  > ./artifacts/schemas/MySettings.v1.json
#  > ./artifacts/schemas/MySettings.v2.json
#  > ...

Note

When --cw-generate-json-schema is specified, the application exits immediately after schema generation is complete. This enables automatic schema generation in CI/CD pipelines and similar scenarios.

You are free to decide where to host this file. For example, consider these approaches:

  • Place it on the main branch of a GitHub repository.
  • Host it on a CDN or your own web server.
  • Include it with release binaries.

Embedding Schema Information

Similarly, enable this through configuration. You can configure it freely based on the URI where you will distribute the schema.

// relative path to the schema files (include release assets)
conf.SchemaBaseUri = "./schemas/";
// hosted on GitHub
conf.SchemaBaseUri = "https://raw.githubusercontent.com/username/repo/main/schemas/";
// hosted on your own server
conf.SchemaBaseUri = "https://example.com/schemas/";

Note

Do not specify a concrete filename in this URI. The filename is added automatically.

Then, when you save the configuration normally, schema information is automatically embedded at the beginning of the JSON/YAML file.

{
  "$schema": "./schemas/MySettings.v1.json",
  "$version": 1,
  "Name": "custom name",
  "Age": 30
}

Warning

If you specify SectionName, schema information will not be embedded in the generated JSON/YAML file.
This is because it is difficult to specify an accurate JSON schema due to the nature of writing to a part of the settings.
However, schema generation itself is functional, so you can refer to it as needed.

Summary

Schema generation and embedding are enabled with the following configuration:

builder.Services.AddWritableOptions(conf => {
    // class registration
    conf.Add<SampleSetting>();
    // format provider (JSON AOT)
    conf.FormatProvider = new JsonAotFormatProvider(SampleSettingSerializerContext.Default);
    // support JSON Schema generation (--cw-generate-json-schema)
    conf.EnableJsonSchemaGeneration(SampleSettingSerializerContext.Default);
    // embedding schema information in generated files
    conf.SchemaBaseUri = "./schemas/";
});

Yaml support

You can embed schema information in YAML files in the same way. However, VYaml's default configuration writes items in camelCase, while JSON Schema's default configuration writes in PascalCase. Therefore, you need to change one of these settings.

builder.Services.AddWritableOptions(conf => {
    // class registration
    conf.Add<SampleSetting>();
    // format provider (YAML)
    conf.FormatProvider = new YamlFormatProvider {
        // When explicitly specifying, set NamingConvention as follows
        SerializerOptions = new YamlSerializerOptions {
            NamingConvention = YamlNamingConvention.CamelCase
        }
    };
    // support JSON Schema generation (--cw-generate-json-schema)
    conf.EnableJsonSchemaGeneration(SampleSettingSerializerContext.Default);
    // embedding schema information in generated files
    conf.SchemaBaseUri = "./schemas/";
});

// Change JSON Schema side to camelCase
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(SampleSetting))]
public partial class SampleSettingSerializerContext : JsonSerializerContext;

Schema information is embedded at the beginning of the YAML file as follows.

# yaml-language-server: $schema=./schemas/MySettings.v1.json
$version: 1
name: custom name
age: 30

Adopting Existing Settings

You can adopt this library while keeping existing configuration files.

Inspect the Existing Schema

The JSON and YAML providers use the $version key for internal schema versioning. Therefore, you need to verify the following points beforehand:

  • Is $version already used in the configuration file,
  • Is schema versioning already performed under a different name?

If it matches, you can change the schema name for version management.

// By default, "$version" is used.
conf.FormatProvider.SchemaVersionProperty = "$schema_version";
// If an older version used a different name, you can also specify a Fallback.
// By default, "Version" is referenced as a fallback (for compatibility up to Ver 0.8)
// If it's already used for another purpose, explicitly override it.
conf.FormatProvider.SchemaVersionFallbackProperties = ["my_version"];

Mark the Root Settings Model

Add OptionsModelAttribute and the partial modifier to the root model that represents the entire configuration file.

[OptionsModel(Id = "UserSetting", Version = 1)]
public partial class UserSetting // mark as partial
{
    public string Theme { get; set; } = "System";
    public ChildSetting Child { get; set; } = new();
}

// Nested models do not need `OptionsModelAttribute`.
public class ChildSetting
{
    public string Name { get; set; } = "default";
}

If the current configuration file does not contain a schema version property, it is treated as Version 1.

Note

You can also change the settings model while adopting this library.
Define the existing schema as Version 1 and the new schema as Version 2.
See Migration for details.

Migration

Configuration files are meant to evolve over time.
This library provides a mechanism to facilitate easy migration of configuration files.

Adding or Removing Properties

This is straightforward. Simply add or remove properties from the configuration class. Most providers handle this without issues. When adding properties, it is recommended to provide default values.

[OptionsModel(Id = "UserSetting", Version = 1)]
public partial class UserSetting
{
    public string FirstName { get; set; } = "first";
    public string LastName { get; set; } = "last";
    // Add new property with default value
    public int Age { get; set; } = 20;
    // Remove old property
    // public bool OldProperty { get; set; } = false;
}

Incompatible Changes

When schema changes are incompatible, you need to increment the version.

For example, consider consolidating FirstName and LastName into a single Name property. In this case, keep the current class as V1 with a different name, and update the existing class to Version = 2.

// Version 2 (New Version)
[OptionsModel(Id = "UserSetting", Version = 2)]  // <- change the version to 2
public partial class UserSetting
{
    public string Name { get; set; } = "default name";
    public int Age { get; set; } = 20;
}

// Version 1 (Old Version)
[OptionsModel(Id = "UserSetting", Version = 1)]  // <- keep the version as 1
public partial class UserSettingV1 // <- rename UserSetting to UserSettingV1
{
    public string FirstName { get; set; } = "first";
    public string LastName { get; set; } = "last";
    public int Age { get; set; } = 20;
}

Then implement the migration method. The interface will be automatically provided, so you just need to implement it.

public partial class UserSetting
{
    public UserSetting Migrate(UserSettingV1 source)
    {
        return new UserSetting() {
            // combine FirstName and LastName into Name
            Name = $"{source.FirstName} {source.LastName}",
            // Make sure to copy other properties as well.
            Age = source.Age,
        };
    }
}

With just this, the library automatically handles the following:

  • Load the configuration file and check the Version.
  • If Version = 1, load as UserSettingV1, then call Migrate to convert to UserSetting.
  • If Version = 2, load directly as UserSetting.
  • When saving, automatically convert to the latest version.

Even if versions up to Version = 10 exist, the library automatically converts to the latest version by calling Migrate sequentially from Version = 1.

When ending support for older configurations

If you want to intentionally stop supporting older versions, you can start a new compatibility chain.

// Add "SupportMigration = false" to the OptionsModel attribute
// to indicate that migration from older versions is not supported.
[OptionsModel(Id = "UserSetting", Version = 5, SupportMigration = false)]
public partial class UserSetting
{
    public string NewConfiguration { get; set; } = "default";

    // No need to implement the Migrate method.
}

Changing the Save Location

Suppose you previously saved to ./usersettings.json but now want to save to {UseStandardSaveDirectory}/usersettings.json.

First, configure the system to read both files with priority.

builder.Services.AddWritableOptions(conf => {
    conf.Add<UserSetting>(c => {
        // 1. first, try to load from the new location
        c.UseStandardSaveDirectory("MyAppId")
            .AddFilePath("usersettings");
        // 2. fallback to the old location
        c.UseExecutableDirectory()
            .AddFilePath("usersettings");
    });
    // 3. automatically promote settings loaded from the old location at startup
    conf.EnablePromoteSaveLocation();
});

After that, it will automatically migrate to the new location when the application starts.

Changing the Format Provider

For example, suppose v1 used JSON format but v2 switched to YAML format. In this case, register the old format as a fallback using AddFallbackFormatProvider in addition to the standard FileProvider.

builder.Services.AddWritableOptions(conf => {
    // use YAML format for the new version
    conf.FormatProvider = new YamlFormatProvider();
    // support fallback to JSON format for older versions
    conf.AddFallbackFormatProvider(
        new JsonAotFormatProvider(MyJsonContext.Default)
    );
    conf.Add<UserSetting>(c => {
        // Do not include the file extension
        c.UseFile("usersettings");
    });
});

Warning

Do not include the file extension when specifying the filename. The provider automatically detects and appends it.

This automatically performs the following:

  • If V2 (YAML) exists
    • Load it as is.
  • If only V2 (JSON) or V1 (JSON) exists
    • Load V1, migrate to V2, and immediately save as V2 (YAML).
    • The old file is backed up and then deleted.

Note

If SectionName is used, format changes will not be applied to avoid breaking settings in other sections.

Utility APIs

Edit Settings Before Saving

Use BeginConfigure when a settings screen needs to apply several changes together. The session is in-memory until CommitAsync is called; discard the session to abandon changes. Use Update for edits to the in-memory draft.

var session = options.BeginConfigure();

// Temporarily update the draft.
session.Update(setting => {
    setting.Name = "new name";
    setting.Age = 30;
});

// Get the updated value.
Console.WriteLine(session.CurrentValue.Name);

// Reset selected values to the values loaded when the session began.
session.ResetToLoaded((draft, loaded) => draft.Name = loaded.Name);

// Reset selected values, or the complete draft, to `new UserSetting()`.
session.ResetToDefault((draft, defaults) => draft.Name = defaults.Name);
session.ResetToDefault();

// Restore the complete draft to the value loaded when the session began.
session.ResetToLoaded();

// Save the changes when they are ready. To discard them, simply do not commit.
await session.CommitAsync();

Profiles

Profile names and the active profile are persisted in ProfileCatalog; each profile is stored below Profiles:{name} in the same file. The configured default profile is available immediately and is persisted on its first save.

builder.Services.AddWritableOptions(options => {
    options.AddProfiled<UserSetting>(conf => {
        conf.UseFile("usersettings.json");
        conf.SectionName = "MySettings";
        conf.DefaultProfile = "default"; // The profile initially set as active
    });
});

public class ProfileService(IProfiledWritableOptions<UserSetting> profiles) {
    public async Task SwitchToWorkAsync() {
        // Read and save the active profile.
        var name = profiles.CurrentValue.Name;
        await profiles.SaveAsync(setting => setting.Name = "my settings");

        // Create a new profile by copying an existing profile.
        await profiles.CreateProfileAsync("Work", copyFrom: profiles.DefaultProfile);
        // Make it active.
        await profiles.SetActiveProfileAsync("Work");
        Console.WriteLine($"Current Profile: {profiles.ActiveProfileName}");

        // This reads and updates the Work profile.
        var newName = profiles.CurrentValue.Name;

        // Save a specific profile.
        await profiles.GetProfile("Work").SaveAsync(setting => {
            setting.Name = "Work settings";
        });
    }
}

The resulting JSON is structured as follows:

{
  "MySettings": {
    // The available profiles, used internally for lookup.
    "ProfileCatalog": {
      "ActiveProfileName": "Work",
      "ProfileNames": ["default", "Work"]
    },
    "Profiles": {
      // The settings for each profile.
      "default": {
        "Name": "default settings"
      },
      "Work": {
        "Name": "Work settings"
      }
    }
  }
}

Advanced Usage

Support NativeAOT

With a few settings, you can use this library in NativeAOT environments. The following two steps are required:

  1. Prepare a JsonSerializerContext and OptionsValidator
  2. Use JsonAotFormatProvider instead of JsonFormatProvider

DataAnnotations validation is disabled automatically in NativeAOT. Use a source-generator-based validator when validation is required.

// 1. prepare JsonSerializerContext and OptionsValidator
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SampleSetting))]
public partial class SampleSettingSerializerContext : JsonSerializerContext;

[OptionsValidator]
public partial class SampleSettingValidator : IValidateOptions<SampleSetting>;

// -----
// 2. use JsonAotFormatProvider
conf.FormatProvider = new JsonAotFormatProvider(SampleSettingSerializerContext.Default);

// DataAnnotations validation is disabled automatically in NativeAOT.
// Use a source-generator-based validator when validation is required.
conf.WithValidator<SampleSettingValidator>();

For more details, please refer to the Example.ConsoleApp.NativeAOT project.

Clone Strategy

To improve performance, the configuration file is not read every time. Instead, it is loaded and stored as an internal cache when a change event is detected. To prevent direct editing of this cache, a deep copy is created and provided to the user each time it is retrieved or saved.

By default, deep copying of the settings class is supported via IDeepCloneable. This is sufficient for most cases, but if you want to use a different cloning method, you can customize it with conf.UseCustomCloneStrategy.

conf.UseCustomCloneStrategy(original => {
    // Any custom cloning library can be used
    return original.DeepClone();
});

Low-level APIs

InstanceName

If you want to manage multiple settings of the same type, you must specify different InstanceName for each setting.

builder.Services.AddWritableOptions(options => {
    // first setting
    options.Add<UserSetting>("First", conf => {
        conf.UseFile("firstsettings.json");
    });
    // second setting
    options.Add<UserSetting>("Second", conf => {
        conf.UseFile("secondsettings.json");
    });
});

And use IReadOnlyNamedOptions<T> and IWritableNamedOptions<T> to access them.

// use IReadOnlyNamedOptions<T> to read, IWritableNamedOptions<T> to read and write
public class MyService(IWritableNamedOptions<UserSetting> options) {
    public async Task GetAndSaveAsync() {
        var firstSetting = options.Get("First");
        var secondSetting = options.Get("Second");
        await options.SaveAsync("First", setting => {
            setting.Name = "first name";
        });
        await options.SaveAsync("Second", setting => {
            setting.Name = "second name";
        });

        // If specifying the name each time is cumbersome, you can also use GetInstance
        // By doing so, you can handle it in the same way as regular IReadOnlyOptions/IWritableOptions.
        var firstOptions = options.GetInstance("First");
        var firstSetting2 = firstOptions.CurrentValue;
        await firstOptions.SaveAsync(setting => {
            setting.Name = "first name 2";
        });
    }
}

// Alternatively, you can also use IWritableOptions<T> with the [FromKeyedService] attribute
public class MyOtherService(
    [FromKeyedService("First")]
    IWritableOptions<UserSetting> firstOptions
) {
    public async Task GetAndSaveAsync() {
        var firstSetting = firstOptions.CurrentValue;
        await firstOptions.SaveAsync(setting => {
            setting.Name = "first name";
        });
    }
}

If RegisterAsSingleton is enabled, you can access it as follows:

public class MyService([FromKeyedService("First")] UserSetting options) {
    public void DirectUseNamedInstance() {
        // you can use the instance directly
        Console.WriteLine($">> Name: {options.Name}");
    }
}

Note

When not using DI (direct use of WritableOptions), managing multiple configurations is intentionally not supported. This is to avoid complicating usage.

RegisterAsSingleton

If you want to directly reference the settings class, specify conf.RegisterAsSingleton = true.

Tip

The dynamic update functionality provided by IReadOnlyOptions<T> will no longer be available. Be mindful of lifecycle management, as settings applied during instance creation will be reflected.

builder.Services.AddWritableOptions(conf => {
    conf.Add<UserSetting>(c => c.RegisterAsSingleton = true);
});

// you can use UserSetting directly
public class MyService(UserSetting setting) {
    public void Print() {
        Console.WriteLine($">> Name: {setting.Name}");
    }
}

// and you can also use IReadOnlyOptions<T> as usual
public class MyOtherService(IReadOnlyOptions<UserSetting> options) {
    public void Print() {
        var setting = options.CurrentValue;
        Console.WriteLine($">> Name: {setting.Name}");
    }
}

Note

Of course, this feature can only be used in a DI environment.

Dynamic Add/Remove Options

You can dynamically add or remove writable options at runtime using IWritableOptionsConfigRegistry. for example, in addition to common application settings, it is useful when you want to have individual settings for each document opened by the user.

// use IWritableOptionsConfigRegistry from DI
public class DynamicOptionsService(IWritableOptionsConfigRegistry<UserSetting> registry) {
    public void AddNewOptions(string instanceName, string filePath) {
        registry.TryAdd(instanceName, conf => {
            conf.UseFile(filePath);
        });
    }

    public void RemoveOptions(string instanceName) {
        registry.TryRemove(instanceName);
    }
}

// and you can access IOptionsNamedMonitor<T> or IWritableNamedOptions<T> as usual
public class MyService(IWritableNamedOptions<UserSetting> options) {
    public void UseOptions() {
        var commonSetting = options.Get("Common");
        var documentSetting = options.Get("UserDocument1");
        var name = documentSetting.Name ?? commonSetting.Name ?? "default";
        Console.WriteLine($">> Name: {name}");

        // and save to specific instance
        await options.SaveAsync("UserDocument1", setting => {
            setting.Name = "document specific name";
        });
    }
}

Testing

The testing helpers are provided by the separate Configuration.Writable.Testing package. Install it alongside Configuration.Writable to use the examples below:

dotnet add package Configuration.Writable.Testing

Using a Mock

If you simply want to obtain IReadOnlyOptions<T> or IWritableOptions<T>, using WritableOptionsStub is straightforward.

using Configuration.Writable.Testing;

var settingValue = new UserSetting();
var options = WritableOptionsStub.Create(settingValue);

// and use options in your test
var yourService = new YourService(options);
yourService.DoSomething();

// settingValue is updated when yourService changes it
Assert.Equal("expected name", settingValue.Name);

Use ProfiledOptionsStub when testing services that depend on IProfiledReadOnlyOptions<T> or IProfiledWritableOptions<T>.

using Configuration.Writable.Testing;

var profiles = ProfiledOptionsStub.Create(
    new UserSetting { Name = "Personal settings" },
    defaultProfile: "Personal"
);
await profiles.CreateProfileAsync("Work", copyFrom: "Personal");
await profiles.SetActiveProfileAsync("Work");

var yourService = new YourService(profiles);
await yourService.UpdateAsync();

Assert.Equal("expected name", profiles.CurrentValue.Name);
Assert.Equal("Personal settings", profiles.GetProfile("Personal").CurrentValue.Name);

Reading and Writing Actual Files

If you want to perform tests that actually involve writing to the file system, use WritableOptionsSimpleInstance.

var sampleFilePath = Path.GetTempFileName();
var instance = new WritableOptionsSimpleInstance<UserSetting>();
instance.Initialize(conf => {
    conf.UseFile(sampleFilePath);
});
var option = instance.GetOptions();

// and use options in your test
var yourService = new YourService(options);
yourService.DoSomething();

// sampleFilePath now contains the updated settings
var json = File.ReadAllText(sampleFilePath);
Assert.Contains("expected name", json);

Interfaces

Here, we describe the main interfaces provided by this library.

IReadOnlyOptions / IWritableOptions (recommended)

These are the primary interfaces for reading and writing settings. They provide the latest values at the current point in time, and when the configuration file is updated, the latest values are automatically reflected.

  • IReadOnlyOptions<T>
    • A simple read-only options interface that does not support named access.
    • Use the .CurrentValue property to access the current value.
    • Use the .ConfigurationInfo property to access provider-independent metadata.
    • Use the OnChange(Action<T> listener) method to monitor changes to the options.
  • IWritableOptions<T>
    • In addition to IReadOnlyOptions<T>, this supports saving settings via SaveAsync.

Use IReadOnlyOptions<T>.ConfigurationInfo when you need provider-independent metadata such as the effective read path, next write path, format extension, instance name, or section.

IReadOnlyNamedOptions / IWritableNamedOptions

Named variants of the above interfaces. Use these when you manage multiple settings of the same type with different InstanceName values.

  • IReadOnlyNamedOptions<T>
    • Use the .Get(name) method to access named options.
    • Use the OnChange(string name, Action<T> listener) method to monitor changes to specific named options.
    • Use GetInstance(name) to retrieve a pre-specified IReadOnlyOptions<T> instance.
  • IWritableNamedOptions<T>
    • In addition to IReadOnlyNamedOptions<T>, this supports saving settings via SaveAsync(name, ...).
Other interfaces (for compatibility)

IOptions

Provides the value at application startup. Even if the configuration file is updated later, accessing through this interface will not reflect the changes.
Named access is not supported. Only the unnamed instance is accessible via the .Value property.

This is identical to MS.E.O.'s IOptions.

IOptionsSnapshot

Provides the latest value per request (Scoped). The content of the configuration file at the time the object is created is reflected, and even if the configuration file is updated later, the latest value is not reflected.
Named access is supported via the .Get(name) method.

This is identical to MS.E.O.'s IOptionsSnapshot.

IOptionsMonitor

Provides the latest value at the current time. When the configuration file is updated, the latest value is automatically reflected.
Both named and unnamed access are supported; for unnamed access, use .CurrentValue, and for named access, use .Get(name).
Change detection is done by registering a callback with the OnChange(Action<T, string> listener) method. Since changes for both unnamed and named instances are detected, you need to identify the target name from the second string argument as needed.

This is identical to MS.E.O.'s IOptionsMonitor.

License

This project is licensed under the Apache-2.0 License.

About

Yet another configuration library with features including: type-safe operations, change detection, version migration, validation, and more.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Contributors

Languages