Microsoft.Extensions.Primitives
Overview
Microsoft.Extensions.Primitives is a low-level package that provides foundational types used throughout the .NET extensions ecosystem. Its two main areas are change notification (IChangeToken, ChangeToken, CancellationChangeToken, CompositeChangeToken) and efficient string handling (StringSegment, StringTokenizer, StringValues).
The change token infrastructure is the mechanism that powers configuration reloading, file-watcher notifications, and options monitoring in Microsoft.Extensions.Configuration and Microsoft.Extensions.FileProviders. Understanding these types is essential when building custom configuration providers, file-watching services, or any component that needs to react to external changes without polling.
IChangeToken and ChangeToken.OnChange
IChangeToken represents a signal that something has changed. ChangeToken.OnChange subscribes a callback that is invoked when the token fires and automatically re-subscribes for subsequent changes.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using Microsoft.Extensions.Logging;
public sealed class ConfigurationWatcher
{
private readonly IConfiguration _configuration;
private readonly ILogger<ConfigurationWatcher> _logger;
private readonly IDisposable _subscription;
public ConfigurationWatcher(
IConfiguration configuration,
ILogger<ConfigurationWatcher> logger)
{
_configuration = configuration;
_logger = logger;
_subscription = ChangeToken.OnChange(
changeTokenProducer: () => _configuration.GetReloadToken(),
listener: OnConfigurationChanged);
}
private void OnConfigurationChanged()
{
string? newValue = _configuration["FeatureFlags:MaxRetries"];
_logger.LogInformation("Configuration reloaded. MaxRetries is now {Value}", newValue);
}
public void Dispose() => _subscription.Dispose();
}
CancellationChangeToken
CancellationChangeToken wraps a CancellationToken to create an IChangeToken. This is useful when building custom providers that signal changes programmatically.
using Microsoft.Extensions.Primitives;
using System.Threading;
public sealed class ManualChangeTokenSource
{
private CancellationTokenSource _cts = new();
public IChangeToken GetChangeToken()
{
return new CancellationChangeToken(_cts.Token);
}
public void SignalChange()
{
var previous = Interlocked.Exchange(
ref _cts, new CancellationTokenSource());
previous.Cancel();
previous.Dispose();
}
}
using Microsoft.Extensions.Primitives;
// Usage
var source = new ManualChangeTokenSource();
ChangeToken.OnChange(
() => source.GetChangeToken(),
() => Console.WriteLine("Change detected!"));
// Trigger the notification
source.SignalChange(); // Prints: Change detected!
source.SignalChange(); // Prints: Change detected! (re-subscribes automatically)
CompositeChangeToken
CompositeChangeToken combines multiple change tokens into one, firing when any of the underlying tokens fires.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
public sealed class MultiSourceWatcher
{
public MultiSourceWatcher(
IConfiguration configuration,
IFileProvider fileProvider)
{
var configToken = configuration.GetReloadToken();
var fileToken = fileProvider.Watch("data/*.json");
var composite = new CompositeChangeToken(new List<IChangeToken>
{
configToken,
fileToken
});
ChangeToken.OnChange(
() => new CompositeChangeToken(new List<IChangeToken>
{
configuration.GetReloadToken(),
fileProvider.Watch("data/*.json")
}),
() => Console.WriteLine("Configuration or data files changed"));
}
}
Building a Custom Configuration Provider with Change Tokens
A practical example that polls a database for configuration changes and signals via IChangeToken.
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using System.Threading;
public sealed class PollingConfigurationProvider : ConfigurationProvider, IDisposable
{
private readonly Timer _timer;
private CancellationTokenSource _cts = new();
private readonly string _connectionString;
public PollingConfigurationProvider(string connectionString, TimeSpan interval)
{
_connectionString = connectionString;
_timer = new Timer(_ => PollForChanges(), null, interval, interval);
}
public override void Load()
{
// Initial load from database
Data = LoadFromDatabase();
}
private void PollForChanges()
{
var newData = LoadFromDatabase();
if (!DataEquals(Data, newData))
{
Data = newData;
OnReload(); // This triggers the IChangeToken
}
}
private Dictionary<string, string?> LoadFromDatabase()
{
// Load configuration from database
var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
// ... database query logic ...
return data;
}
private static bool DataEquals(
IDictionary<string, string?>? a,
IDictionary<string, string?> b)
{
if (a is null || a.Count != b.Count) return false;
foreach (var kvp in a)
{
if (!b.TryGetValue(kvp.Key, out var value) || value != kvp.Value)
return false;
}
return true;
}
public void Dispose()
{
_timer.Dispose();
_cts.Dispose();
}
}
StringSegment and StringTokenizer
StringSegment provides a zero-allocation view into a string, and StringTokenizer splits strings without allocating substrings.
using Microsoft.Extensions.Primitives;
// StringSegment avoids allocations for substring operations
StringSegment segment = new StringSegment("Content-Type: application/json", 14, 16);
// segment.Value == "application/json"
// segment.Length == 16
bool isJson = segment.Equals("application/json", StringComparison.OrdinalIgnoreCase);
// StringTokenizer splits without allocating an array
var header = new StringSegment("gzip, deflate, br");
var tokenizer = new StringTokenizer(header, new[] { ',', ' ' });
foreach (StringSegment token in tokenizer)
{
if (!token.HasValue || token.Length == 0) continue;
Console.WriteLine(token.Value); // "gzip", "deflate", "br"
}
StringValues
StringValues represents zero, one, or many strings in a single struct, commonly used in HTTP headers and query strings.
using Microsoft.Extensions.Primitives;
// Single value
StringValues single = "text/html";
Console.WriteLine(single.Count); // 1
// Multiple values
StringValues multiple = new StringValues(new[] { "gzip", "deflate", "br" });
Console.WriteLine(multiple.Count); // 3
// Concatenation
StringValues combined = StringValues.Concat(single, multiple);
Console.WriteLine(combined.Count); // 4
// Equality checks
bool hasGzip = StringValues.Equals(multiple, new StringValues("gzip"));
// Iteration
foreach (string? value in multiple)
{
Console.WriteLine(value);
}
Key Types at a Glance
| Type | Purpose | Common Usage |
|---|---|---|
IChangeToken | Signal that a state change occurred | Configuration reload, file watching |
CancellationChangeToken | Adapts CancellationToken to IChangeToken | Custom change providers |
CompositeChangeToken | Merges multiple change tokens | Multi-source watching |
ChangeToken.OnChange | Subscribes with auto-resubscription | Callback on configuration change |
StringSegment | Zero-allocation string slice | Header parsing, URL processing |
StringTokenizer | Allocation-free string splitting | CSV, header list parsing |
StringValues | Multi-value string collection | HTTP headers, query parameters |
Best Practices
- Always dispose the
IDisposablereturned byChangeToken.OnChangewhen the subscribing object is no longer needed to prevent memory leaks from dangling callbacks. - Keep change-token callbacks short and non-blocking; offload expensive work to a background task or channel rather than doing I/O inside the callback itself.
- Use
CancellationChangeTokenwith the swap-and-cancel pattern (viaInterlocked.Exchange) to build re-usable change sources that can fire multiple times. - Prefer
StringSegmentoverstring.Substringin hot paths like header parsing or URL routing where avoiding heap allocations improves throughput. - Recreate tokens in the
changeTokenProducerlambda passed toChangeToken.OnChangerather than capturing a single token, because each token fires only once. - Use
CompositeChangeTokento merge file-watcher and configuration-reload tokens when a component depends on both sources, avoiding separate subscription management. - Avoid storing references to
IChangeTokeninstances beyond their useful lifetime; tokens are single-use and become inert afterHasChangedreturnstrue. - Use
StringValues.IsNullOrEmptyto check for missing or empty header values instead of null-checking and length-checking separately. - When building a custom
ConfigurationProvider, callOnReload()to fire the provider's change token rather than managing your ownCancellationTokenSourceexternally. - Write unit tests for change-token producers by asserting
HasChangedtransitions fromfalsetotrueand that registered callbacks execute exactly once per signal.