JSON Configuration

JSON Configuration

Builder reference

MethodEffect
OverrideDirectory(string)Directory the instance’s files resolve against. Defaults to JsonConfigManager.DefaultDirectory ("./config").
Register<T>(string fileName, bool encrypted = false)Associates T with an existing JSON file.
OverrideJsonOptions(JsonSerializerOptions)Replaces the default serializer options entirely, rather than merging into them.
ApplyConverterFor<T>(JsonConverter<T>)Swaps in a custom converter for T. See Custom converters.
ApplyEncryptionKey(Func<string>)Supplies the passphrase for encrypted: true registrations. See Encryption.
EnableHotReload()Watches the directory for file changes and invalidates affected cache entries. See Hot Reload.
EnsureIsolatedGets()Returns a fresh deserialize from Get<T> on every call instead of the cached reference. See Isolated gets.
ApplyValidatorFor<T>(Func<T, bool>, string reason)Attaches a build-time (and Put-time) validator. See Validation.
Build()Validates every registration and returns the live instance, or throws.

Registering a config type

Register<T>(fileName, encrypted = false) associates a plain C# type with a JSON file in the instance’s directory:

var configMgr = JsonConfigManager.BuildInstance("default")
    .OverrideDirectory(@"C:\data\myapp\config")  // defaults to "./config"
    .ApplyEncryptionKey(() => "at-least-twelve-characters")
    .Register<AppSettings>("app-settings.json")
    .Register<ConnectionStrings>("connection-strings.aes", encrypted: true)
    .Build();
There is no Create<T>: app-settings.json must already exist under the configured directory by Build() time. Authoring the initial file (plaintext, or pre-encrypted via the CLI Tool or ConfigEncryptor) is the caller’s responsibility, outside the manager.

Registering the same type twice on one instance throws InvalidOperationException. Registering encrypted: true has its own build-time requirement; see Encryption.

File naming and directory scoping

An instance is scoped to exactly one flat directory. fileName must be a plain file name, with no path separators, and not rooted:

.Register<T>("../secrets.json")     // ArgumentException: escapes the configured directory
.Register<T>(@"C:\secrets.json")    // ArgumentException: rooted
.Register<T>(@"sub\config.json")    // ArgumentException: contains a path separator

This is enforced even when the target file genuinely exists: a subdirectory fileName is rejected regardless. To organize configs into folders, build a separate named instance per subdirectory, each with its own OverrideDirectory:

var connStrMgr = JsonConfigManager.BuildInstance("conn-strings")
    .OverrideDirectory(@"C:\data\myapp\config\conn-strings")
    .Register<ConnectionStrings>("connection-strings.json")
    .Build();

Reading

AppSettings settings = configMgr.Get<AppSettings>();

Get<T> deserializes and caches on first access, unless T had a validator applied and was already pre-cached during Build() (see Validation). Either way, later calls return the cached instance. Pass refresh: true to force a re-read from disk and repopulate the cache:

AppSettings latest = configMgr.Get<AppSettings>(refresh: true);

Get<T> throws InvalidOperationException if T was never registered on the instance.

Isolated gets

By default, Get<T> returns a reference to the cached object, not a copy. A caller could mutate the instance via the returned reference, a reference-aliasing bug that bypasses the API entirely: no Put call is needed to cause or observe it.

EnsureIsolatedGets() on the builder closes this instance-wide, with no per-type opt-in: instead of caching the deserialized object, the instance caches the raw JSON string and re-deserializes a fresh instance from it on every Get<T> call.

var configMgr = JsonConfigManager.BuildInstance("default")
    .EnsureIsolatedGets()
    .Register<AppSettings>("app-settings.json")
    .Build();

The tradeoff is a deserialize on every Get<T> instead of an O(1) cache hit. Leave it off when callers are trusted not to mutate what Get<T> returns and the cache hit matters; turn it on when an instance is shared across callers or teams where an accidental mutation is a more expensive failure than the extra deserialize.

Writing

AppSettings settings = configMgr.Get<AppSettings>();
settings.TimeoutSeconds = 60;

bool written = configMgr.Put(settings);

Put<T> serializes and writes atomically (write to a temp sibling file, then move into place) and invalidates the cache for that type. It also guards against a lost update: if Put has previously tracked a version for T (from an earlier Get or Put on this instance, or from build-time validation) and the on-disk content has since changed (a concurrent process, a manual edit), Put returns false without writing. The documented recovery is to refresh and retry:

if (!configMgr.Put(settings))
{
    settings = configMgr.Get<AppSettings>(refresh: true); // pick up the external change
    settings.TimeoutSeconds = 60;                          // reapply
    configMgr.Put(settings);                                // retry
}

If no version has been tracked yet for T on this instance, Put writes unconditionally: the concurrency check only applies once this instance has actually observed the file’s content at least once.

Custom converters

ApplyConverterFor<T> swaps in a JsonConverter<T> for the instance’s serializer options. For most T this is optional, but it’s required for a T that System.Text.Json can’t instantiate on its own, most commonly an interface: registering IAccountSettings without a matching converter throws NotSupportedException at Build() (or at the first Get<T> if the type has no validator forcing eager deserialization).

JsonConfigManager.BuildInstance("default")
    .ApplyConverterFor<IAccountSettings>(new AccountSettingsConverter())
    .Register<IAccountSettings>("account-settings.json")
    .Build();

It replaces an existing converter registered specifically for T if one is present, rather than matching anything whose CanConvert(T) returns true. A broad converter like JsonStringEnumConverter for unrelated types is left alone.

Default serializer options

Unless overridden with OverrideJsonOptions, JsonConfigManager uses:

OptionValue
WriteIndentedtrue
MaxDepth512
DefaultBufferSize8192
IncludeFieldsfalse
IgnoreReadOnlyFieldstrue
AllowTrailingCommastrue
PropertyNameCaseInsensitivetrue
ReadCommentHandlingSkip
ConvertersJsonStringEnumConverter pre-registered

JsonConfigManagerBuilder.DefaultJsonOptions is a static property that returns a fresh JsonSerializerOptions populated with this exact table, so a single default can be tweaked without hand-reconstructing the rest:

var options = JsonConfigManagerBuilder.DefaultJsonOptions;
options.PropertyNameCaseInsensitive = false;

JsonConfigManager.BuildInstance("default")
    .OverrideJsonOptions(options)
    .Register<AppSettings>("app-settings.json")
    .Build();

Each call returns a new instance, not a shared one, so mutating the result never affects other builders.

To serialize enums as integers instead of string constants, start from DefaultJsonOptions, clear Converters to drop JsonStringEnumConverter, and pass the result to OverrideJsonOptions; with no enum converter present, System.Text.Json falls back to the underlying integer value.

Cache control

ClearCached(Type) and ClearAllCached() drop cached instances without touching the underlying files: the next Get<T> re-reads from disk. GetFileName(Type) returns the file name a type was registered under, or null if it wasn’t registered.

Next steps

  • Encryption: encrypted registrations, ConfigEncryptor
  • Validation: ApplyValidatorFor<T> and build-time validation
  • Hot Reload: automatic cache invalidation on external file changes