[API Proposal] Add Configuration References
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
**TL;DR.** A configuration value that is exactly $ref(<expression>) reads as the value of another key. Resolution happens on read, inside the configuration root, so it needs no new public API: every existing read path (indexer, GetValue, the binder, IOptions) sees the resolved value, and everything else about configuration is untouched. The feature is on by default and can be turned off, and trimmed away, with an AppContext switch.
# Requirements
1. Configuration is reusable. Given a configuration key, it must be possible to express that another key refers to it, whether the referenced key holds a leaf value (e.g. Database:Credentials:UserName) or a whole section (e.g. Database:Credentials). Users may also be able to change which section is effectively used at runtime.
2. There is no performance regression compared to the current configuration mechanism, especially in the IConfiguration indexers as they may be used in hot-path of their application for historical reasons (albeit using a cached value updated through IOptionsMonitor callback should be preferred).
# Security
The earlier revision of this proposal treated a reference as a shift of control from the application author to the configuration author, and contained it with a rule set naming which keys may hold a reference and which keys those references may point at. That framing does not survive contact with [.NET's baseline security assumptions](https://github.com/dotnet/core/blob/main/Documentation/security-foundations/baseline-security-assumptions.md), §3.1:
> Application configuration is a **control-plane mechanism**. It exists to direct execution flow, select modes, control features, and generally dictate application behavior. Because these decisions belong to a fully-trusted authority, the configuration input is itself fully trusted.
# Proposal
A value that is **exactly** `$ref()` resolves, when read, to the value of the key the expression names. Anything else is text. There is no new public API: the behaviour lives in the configuration root's read path, so it reaches every consumer that reads through `IConfiguration`, including binders and `IOptions`, without any registration.
## Syntax
The reference has to be the whole value, sigil to closing parenthesis, with nothing either side of it. `prefix $ref(A)`, `$ref(A) `, and `$ref(A) trailing` are all ordinary text. The keyword matches case-insensitively, in keeping with configuration keys generally, and the body runs to the final `)`, so `$ref(Weird(1):Key)` names the key `Weird(1):Key`. Surrounding whitespace inside the body is trimmed.
| Feature | Written | Names |
| --- | --- | --- |
| Absolute key | `$ref(Azure:SharedCredential)` | `Azure:SharedCredential` |
| Parent move | `$ref(..:Sibling)` | a sibling of the key holding the reference |
| Repeated move | `$ref(..:..:Cousin)` | a sibling of the parent section |
| Self move | `$ref(.:Own)` | a child of the key holding the reference |
| Quoted segment | `$ref(A:'B:C')` | the single segment `B:C` under `A` |
| Sub-reference | `$ref({Pointer}:Target)` | the key named by splicing in the value of `Pointer` |
| Escape | `$$ref(A)` | nothing; reads as the text `$ref(A)` |
A move is a segment made only of dots, so it has to start a segment and fill it. `Microsoft.AspNetCore`, `Acme Corp.` and `.NET` are therefore ordinary key segments, not moves, and a segment that really is `.` is written `'.'`. Either quote character works, and a quote is doubled to include it: `'a''b'` is the segment `a'b`. A sub-reference is spliced into the expression before the expression is read, and what it splices in is itself a key expression, so `{Up}` where `Up` holds `..` opens a relative expression exactly as a written `..` would. Sub-references sit side by side rather than one inside another; `{A{B}}` is an error.
## Semantics
- **Leaf values only.** A reference names one key and produces one value. It does not mirror the target's subtree, and a reference at a section key that holds no value of its own reads as absent. This is the largest single departure from the earlier revision.
- **Enumeration is untouched.** `GetChildren` lists the referring key under its own name and never brings the target's children along. Values read during enumeration resolve like any other read.
- **Precedence is untouched.** A reference is a value like any other, so a higher-precedence provider holding a literal beats a lower-precedence provider holding a reference, and the usual last-wins rule decides.
- **A missing target reads as absent**, exactly as a missing key does, so the binder leaves the property at its default. A target that holds the empty string was found and reads as the empty string; the two are distinct.
- **Nothing is cached.** A value written through the indexer resolves on the very next read, and a source that reloads is picked up immediately, with no change-token plumbing of its own.
- **A chained configuration's answer is final.** Text arriving from `AddConfiguration` is taken as given, since that configuration has already read its own values, escapes and all.
- **References may point at references.** The chain is followed until it reaches a value.
## Errors and limits
Text that is plainly meant as a reference but cannot be read as one is reported rather than handed back as a literal, because quietly returning the text hides the mistake behind a value that looks like what was typed. `$ref()`, `$ref(Deep:'Target)` and `$ref(Deep:{Target)` all throw `InvalidOperationException` naming the key.
A read that cannot terminate throws, and the message carries the path it took: cycles are detected and reported as the loop itself, and three independent bounds catch runaway expressions, being 64 steps along a chain, 32 levels of sub-reference, and 32 substitutions into any one expression. A target that simply is not there is not an error, per the discussion above.
## Opting out
```
AppContext.SetSwitch("Microsoft.Extensions.Configuration.DisableConfigurationTransformations", true);
```
The switch is a `[FeatureSwitchDefinition]`, so an application that sets it through `RuntimeHostConfigurationOption` with `Trim="true"` has reference resolution removed by the trimmer rather than merely left unreachable. With the switch on, Configuration hands values back exactly as its providers hold them, which is what it does today.
It is named for transformations rather than for references deliberately. A reference is one way a value can read as something other than the literal text a provider holds, and it is unlikely to be the last, so the switch says what it guarantees, which is that nothing between the provider and the caller rewrites a value. That guarantee is what an application wants when it needs a value to survive untouched, and it is why the switch is a supported way to run rather than a temporary escape hatch: an application holding a value that really is the text `$ref(...)` relies on it, and so does anyone diagnosing whether a surprising value came from a provider or from resolution.
## Sample
A common shape with the Azure SDK is to register several clients side by side, each with its own options section, all using the same service principal:
```json
{
"Azure": {
"SharedCredential": {
"ClientId": "...",
"ClientSecret": "..."
},
"Storage": {
"ServiceUri": "https://contoso.blob.core.windows.net",
"Credential": {
"$ref": "Azure:SharedCredential"
"ClientId": "$ref({..:$ref}:ClientId)",
"ClientSecret": "$ref({..:$ref}:ClientSecret)"
}
},
"ServiceBus": {
"FullyQualifiedNamespace": "contoso.servicebus.windows.net",
"Credential": {
"ClientId": "$ref(..:..:..:SharedCredential:ClientId)",
"ClientSecret": "$ref(..:..:..:SharedCredential:ClientSecret)"
}
}
}
}
```
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAzureClients(clients =>
{
clients.AddBlobServiceClient(builder.Configuration.GetSection("Azure:Storage"));
clients.AddServiceBusClient(builder.Configuration.GetSection("Azure:ServiceBus"));
});
```
The registration code is exactly what it is today; there is nothing to add. When the binder reads `Azure:Storage:Credential:ClientId`, the root resolves the reference and hands back the shared value. If the credential later moves to Key Vault, the operator adds the provider and changes nothing else, because resolution reads whatever the providers currently hold.
Note what the leaf-only rule costs here: the earlier revision mirrored `Azure:SharedCredential` under each `Credential` section with one reference each, whereas this one names each leaf. That is the honest trade for leaving enumeration alone, and the next section sets out why it was taken.
## Subtree references
A reference names one key and produces one value. Naming a *section*, so that the reference stands in for a whole subtree and reading through the referring key follows into the target's children, was considered and set aside.
The cost is not in the syntax but in everything a subtree touches. Enumeration has to answer for keys that exist in one place and are declared in another, which brings in `GetChildren` and `GetSection` on the referring side, change notification that fires when the target's *shape* changes rather than only its values, precedence between a mirrored child and a literal one written at the same path, and a cycle rule that reasons about shapes rather than single reads. Each of those has a defensible answer; none has an answer that is obviously right for every application, and something that is on by default cannot ship a debatable answer to a question the user never asked. Leaf-only changes nothing but what a value reads as, which is why it can be on by default at all.
If the demand turns out to be there, the better shape is an extension point rather than a built-in rule, because the application knows things the framework does not: whether a mirrored subtree shadows the literal keys around it or is shadowed by them, whether a reload of the target counts as a change to the referring section, how deep to follow, and what happens when both sides declare the same child. A specific configuration layout can answer those confidently; a general-purpose framework can only pick a compromise and impose it on everyone. Shipping the leaf-only form first keeps that door open, and shipping a guess would close it.
# Alternatives
## 1. Rule-based access model (the earlier revision of this proposal)
References declared as rules on the builder, each naming a subject pattern and the target patterns it may point at, with a glob engine shared between them, longest-template-wins precedence, and an access violation thrown when a reference names a target no rule admits:
```csharp
public static partial class ReferenceConfigurationBuilderExtensions
{
public static IConfigurationBuilder AllowReferences(this IConfigurationBuilder builder, Action configure);
}
public sealed partial class ConfigurationReferenceBuilder
{
public ConfigurationReferenceBuilder Allow(string subject, string target, params string[] additionalTargets);
public ConfigurationReferenceBuilder Deny(string subject, string target, params string[] additionalTargets);
}
```
Internally this materialised rather than resolved: a `ReferenceConfigurationSource` added at the call site, seeing a snapshot of the providers before it, mirroring each resolved subject's target subtree under the subject on `Load`, listening on upstream reload tokens, and re-materialising on change.
**Rejected.** The rule set exists to defend a boundary that .NET's model says is not there, as set out under Security above. Removing it removes the reason for `ConfigurationReferenceBuilder`, which removes the reason for the callback, which leaves `AllowReferences(this IConfigurationBuilder)` as the entire surface. That is where the API review of 2026-06-30 landed on this issue, which removed the access model as over-complication and noted that an `AppContext` opt-out means the feature needs no API at all.
## 2. Use existing API
The reuse in requirement 1 is already achievable with the options and DI machinery. Bind the shared section once and inject it into every consumer that needs it:
```csharp
services.Configure(config.GetSection("Database:Credentials"));
services.AddOptions("primary")
.Configure>((opts, creds) => opts.Credentials = creds.Value);
```
Runtime switching is the named-options pattern already used for logging providers:
```csharp
services.Configure("dev", config.GetSection("Credentials:Dev"));
services.Configure("prod", config.GetSection("Credentials:Prod"));
services.AddOptions()
.Configure, IConfiguration>((opts, creds, cfg) =>
{
opts.Credentials = creds.Get(cfg["Credentials:Active"] ?? "prod");
});
```
**Rejected**, though less firmly than before. The wiring lives in code, where it is searchable, refactorable and reviewable, which is genuinely better for code-base hygiene. What it cannot do is let an operator change where a value comes from without a redeploy, which is the requirement, and it obliges every library that wants the pattern to reinvent the same `Configure` idiom in its own spelling. With the security objection withdrawn, the remaining cost of references is the per-read overhead and the syntax, both small.
## 3. Add syntactic sugar
Keep the wiring in code but collapse the N near-identical idioms into one shared vocabulary, with helpers in `Microsoft.Extensions.Options` that drop the `IOptions<>` wrapper from a dependency and materialise every child of a section as a named binding.
**Rejected as a substitute, worth having on its own merits.** It is a real ergonomic win and adds no new surface beyond four methods, but it only ever picks a different *named bucket*, never a different *target key*, so JSON-only redeploys remain impossible for exactly the cases this proposal is about. It does not compete with references and could ship alongside them.
## 4. `ConfigurationReference` binder attribute
Make references opt-in at the bound POCO, resolved only inside the `Bind`/`Get`/`IOptions` pipeline, with a per-bind-site `ReferenceScope` narrowing what may be read.
**Rejected.** Its entire advantage was the scope, which is the rule set again in a different place, and it costs an attribute, a `BinderOptions` property, a scope-filter helper, six binder overloads, and duplicate resolution logic in both the reflection binder and the source generator. It also splits the world: direct `IConfiguration` consumers, which is most of hosting, logging and third-party plumbing, would see the raw `$ref(...)` text while binder consumers saw the resolved value. Resolving in the root means one answer to the question of what a key holds.
# Out of scope
- **Defaults.** `$ref(A, 'fallback')` or similar. The syntax leaves room for it, since escaping is by doubled sigil rather than by quoting, but no more than room.
- **Custom recognisers.** Swapping `$ref(...)` for another marker. Deferred; the API review did not discuss it, as it was not expected for this release.
- **Subtree mirroring.** Discussed above. Reinstating it means reinstating enumeration changes, which is a much larger change than the read path.
Contributor guide
Assessment
This issue has not been assessed yet.