A strongly-typed options class looks like the most ordinary C# in your solution — a POCO with public properties and nothing clever about it. But the moment you pass it to services.Configure<T>, a few of its names stop being C# names and become keys on the outside of your program: strings in appsettings.json, strings in environment variables, strings on the command line. The configuration binder joins those strings to your property names by reflection, at runtime, and the compiler never watches it happen. That makes the property names exactly the kind of load-bearing surface a blind rename pass destroys.
The failure is the quiet kind. Rename MaxRetries to a, and your app still compiles, still starts, still runs. But the binder, looking for a property called MaxRetries to receive the value from configuration, finds nothing — so it leaves the property at its default. No exception is thrown, because an unmatched configuration key is not an error. You ship an options object full of zeros and nulls, and a retry policy that never retries.
How the binder matches a key to a property
Start with an unremarkable options class and the section it binds from:
public sealed class EmailOptions { public string ConnectionString { get; set; } = ""; public int MaxRetries { get; set; } = 3; public int TimeoutSeconds { get; set; } = 30; public bool UseTls { get; set; } = true; } { "Email": { "ConnectionString": "smtp://mail.internal:587", "MaxRetries": 5, "TimeoutSeconds": 15, "UseTls": true } } You wire the two together in the composition root:
builder.Services.Configure<EmailOptions>( builder.Configuration.GetSection("Email")); Note which string lives where. The section name — "Email" — is a string literal in your code, passed to GetSection. Nebula leaves string literals intact by default (and encrypts rather than renames them when encryptStrings is on), so the section name is rarely the fragile part. The fragile part is the other end: the keys inside the section — ConnectionString, MaxRetries — which exist only in appsettings.json and must match property names that live only in your compiled assembly.
The reflection binder closes that gap at runtime. Conceptually, ConfigurationBinder.Bind does the equivalent of this:
// Simplified from Microsoft.Extensions.Configuration.ConfigurationBinder. object instance = Activator.CreateInstance(typeof(EmailOptions))!; // needs a parameterless ctor foreach (PropertyInfo prop in typeof(EmailOptions) .GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (!prop.CanWrite) continue; IConfigurationSection child = section.GetSection(prop.Name); // key == property NAME if (child.Value is not null) prop.SetValue(instance, Convert(child.Value, prop.PropertyType)); } Two things are now load-bearing. First, Activator.CreateInstance needs an accessible parameterless constructor. Second, each key is matched to prop.Name — the live reflected name of the property. Rename the property and prop.Name changes with it; the key in appsettings.json does not. The match fails, SetValue is never called, and the property keeps its CLR default. The compiler is not involved in any of this, which is why the build stays green while the behavior goes wrong.
Consuming the options is the same regardless:
public sealed class EmailSender(IOptions<EmailOptions> options) { private readonly EmailOptions _cfg = options.Value; public async Task SendAsync(/* ... */) { for (int attempt = 0; attempt <= _cfg.MaxRetries; attempt++) { /* ... */ } } } If MaxRetries bound to its default of 0 because the key no longer matched, SendAsync makes exactly one attempt and gives up — a defect that never surfaces in a unit test that constructs EmailOptions directly, only in production where the value comes through configuration.
The source-generated binder changes the mechanism, not the dependency
.NET 8 introduced a configuration-binding source generator you opt into with a project property:
<PropertyGroup> <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> </PropertyGroup> With it on, the Configure<EmailOptions> call is intercepted at compile time and the reflection above is replaced by generated, strongly-typed code — good for Native AOT and trimming, since there is no longer a reflection walk to preserve. But look at what the generator emits:
// Generated binder (shape). The key is a string LITERAL taken from the property name. if (section["ConnectionString"] is string s0) instance.ConnectionString = s0; if (section["MaxRetries"] is string s1) instance.MaxRetries = ParseInt(s1); The key "MaxRetries" is frozen into the generated code as a literal, derived from the property name at compile time. Post-compile obfuscation renames the property token and the generated assignment together — they stay consistent — but the baked key literal and your appsettings.json still have to agree, and that literal is still the original name MaxRetries. So the source generator trims the reflection without removing the requirement that the configuration key equal the property name. You still pin the names; you just also get AOT-friendliness. (And because other paths — validation, IConfiguration diagnostics, options change tracking — may still read the real name reflectively, keeping the names is the safe default under either binder.)
Validating that binding actually happened
The quiet-failure problem is worth closing from the application side too, independent of obfuscation. Options validation turns a silently-unbound value into a loud startup failure:
public sealed class EmailOptions { [Required] public string ConnectionString { get; set; } = ""; [Range(1, 10)] public int MaxRetries { get; set; } = 3; [Range(1, 120)] public int TimeoutSeconds { get; set; } = 30; public bool UseTls { get; set; } = true; } builder.Services.AddOptions<EmailOptions>() .Bind(builder.Configuration.GetSection("Email")) .ValidateDataAnnotations() .ValidateOnStart(); For rules the attributes cannot express, implement IValidateOptions<T> — or use the [OptionsValidator] source generator, which produces the validator for you without reflection:
[OptionsValidator] public sealed partial class ValidateEmailOptions : IValidateOptions<EmailOptions> { } With ValidateOnStart, a MaxRetries that came back 0 because its key failed to bind now fails [Range(1, 10)] and the host refuses to start with a precise message, instead of running with a broken retry policy. That converts exactly the obfuscation regression this post is about into a fail-fast at boot — a cheap safety net on top of a correct keep-list, not a substitute for one.
The keep-list in nebula.config.json
Nebula does not guess which properties are bound from configuration. You declare the options types as keep rules scoped to their public surface, and let a catch-all rename everything else:
{ "schemaVersion": 1, "inputs": [ "bin/Release/net8.0/MyApp.dll" ], "outputDirectory": "protected", "encryptStrings": true, "controlFlowObfuscation": true, "renaming": { "rules": [ { "pattern": "MyApp.Configuration.*Options", "action": "keep", "members": "public" }, { "pattern": "MyApp.*", "action": "rename" } ] } } The first rule pins every options type under MyApp.Configuration and its public members — the bound properties ConnectionString, MaxRetries and the rest keep their names, and because a public parameterless constructor is part of the public surface it is preserved too, so Activator.CreateInstance still has a constructor to call. Private fields, backing state and any private helpers inside the options type still rename, because members: "public" narrows the keep to the public surface. The class name itself is public here, so it is kept; if you would rather let the type name rename, keep only the properties by matching members explicitly — the binder never reads the type name, only the property names and the constructor.
The second rename catch-all sweeps everything behind the options surface — the services that inject IOptions<EmailOptions>, your validators' bodies, business logic, DTO internals and DI wiring — into full renaming, plus string encryption and control-flow obfuscation. That is the code worth protecting, and none of it is read by name from outside.
For constructor-bound options (records, or a type with a single non-default constructor), the binder matches constructor parameter names to keys, so pin those too — keeping the public members of the record covers its positional properties, and Nebula preserves parameter names on a kept member.
If you prefer to annotate the exact type rather than match a namespace pattern, the standard [Obfuscation] attribute lives next to the code:
[Obfuscation(Feature = "renaming", Exclude = true, ApplyToMembers = true)] public sealed class EmailOptions { /* ... */ } Run the protection as part of the release build, never on developer machines:
<Target Name="NebulaProtect" AfterTargets="Publish" Condition="'$(CI)' == 'true'"> <Exec Command="nebula protect --config nebula.config.json" /> </Target> Or invoke the CLI directly against a published build:
nebula protect --config nebula.config.json Verifying the surface with Glass.NET
A keep-list is a claim about which names survived; check the claim rather than trust it. Open the protected assembly in Glass.NET, Delta1 Labs' free .NET decompiler, and look at two things.
First, the bound surface. Your EmailOptions type should still expose public properties named ConnectionString, MaxRetries, TimeoutSeconds and UseTls, with a public parameterless constructor present. These are the exact names your appsettings.json keys match against; if any came out renamed to a, the binder will leave that value at its default in production and your keep-list pattern missed it.
Second, everything behind the surface. The services that consume the options, the validator bodies, your private helpers and DTO internals should all show renamed identifiers and — with encryptStrings on — opaque string constants. That is the protection you came for, intact on exactly the code worth protecting.
The rule underneath all of this is the one every reflection-driven framework obeys: a name is load-bearing exactly when something outside the CLR's own token resolution reads it as a string. For strongly-typed configuration that set is small and knowable — the public property names of your options POCOs, plus a parameterless constructor — and once you have pinned precisely those, Nebula renames the consumers, the validators and the internals freely, protecting your own shipped application without ever moving a name the configuration binder depends on.
