A pattern that shows up repeatedly in .NET library code — where a public convenience method hardcodes DI resolution and silently blocks entire categories of real-world use.
// General overload — caller supplies the monitor directly
context.EnableFeature(myCustomMonitor);
// ✓ Works. No DI required.
// Library convenience method — hardcodes DI resolution
context.EnableFeature<MyOptions>();
// ✗ InvalidOperationException: No service for type 'IOptionsMonitor`1[MyOptions]'
You can see the feature in the docs. You can find it in the source. But when you try to use it outside the library's preferred infrastructure pattern, you hit an exception. Not a missing feature. An artificial wall built into the public surface while the implementation sits quietly capable behind it.
The Pattern
A library exposes a method like this:
// What callers see — the only public path
public void EnableFeature<TOptions>(string? name = null)
=> _context.EnableFeatureCore(
_serviceProvider.GetRequiredService<IOptionsMonitor<TOptions>>(),
name);
// Internal implementation — already general
internal static void EnableFeatureCore<TOptions>(
IOptionsMonitor<TOptions> monitor, // accepts anything
string? name = null)
{
// doesn't care where monitor came from
}
The public method resolves IOptionsMonitor<TOptions> from DI and passes it to an internal method. The internal method accepts any monitor. But callers can't reach the internal method. They only see the public one, and it demands DI.
How this happens: an engineer writes a general internal implementation, then adds a DI-resolving convenience wrapper for the common case. The convenience method is documented and shipped. The internal method stays internal. A user with a non-standard setup hits the wall.
How to detect it: when a library method doesn't work outside DI, look for this combination:
- The public method calls
GetRequiredService<T>() - It passes the result to another method that accepts T as a parameter
- That other method doesn't care where T came from
If all three are true, there's a missing public overload.
The Four Scenarios It Blocks
1. Third-party configuration sources
Feature flag SDKs (LaunchDarkly, ConfigCat), remote config services (AWS AppConfig, Consul), or custom config stores all have their own change-notification models. Using them forces a workaround:
// ❌ Forced: must register wrapper in DI just to satisfy the library
public class FeatureFlagMonitor<TOptions> : IOptionsMonitor<TOptions> { /* ... */ }
services.AddSingleton<IOptionsMonitor<MyOptions>, FeatureFlagMonitor<MyOptions>>();
context.EnableFeature<MyOptions>(); // DI resolves your wrapper
// ✅ With the general overload:
var monitor = new FeatureFlagMonitor<MyOptions>(featureFlagClient);
context.EnableFeature(monitor); // DI container untouched
The problem isn't implementing IOptionsMonitor<T> — that's reasonable. The problem is being forced to register it in DI when you have no other reason to.
2. Multi-tenant applications
Tenants are discovered at runtime. DI containers are designed for startup-time registration. If a library forces DI resolution, per-tenant configuration becomes genuinely difficult:
// ❌ DI has one global registration — not per-tenant
context.EnableFeature<TenantOptions>(); // gets the GLOBAL monitor — wrong
// ✅ With the general overload:
public SomeFeature GetForTenant(string tenantId)
{
return _cache.GetOrAdd(tenantId, id =>
{
// Create per-tenant monitor at runtime — no startup registration needed
var monitor = new TenantOptionsMonitor<TenantOptions>(_store, id);
context.EnableFeature(monitor);
return BuildFeature(monitor.CurrentValue);
});
}
3. Shared instances across multiple consumers
Sometimes two components must react to the same change event together — not coincidentally, but by design:
// ✅ One monitor, passed explicitly to both consumers
var sharedMonitor = new RemoteConfigMonitor<PaymentConfig>(configService);
featureA.Initialize("payment-read", (builder, ctx) => ctx.EnableFeature(sharedMonitor));
featureB.Initialize("payment-write", (builder, ctx) => ctx.EnableFeature(sharedMonitor));
// When PaymentConfig changes:
// - Both featureA and featureB receive the same event from the same listener list
// - Guaranteed atomic update — no race, no missed events
4. Testing without infrastructure
This is the most universally felt consequence. A DI-only public API turns unit tests into integration tests:
// ❌ With DI coupling — integration test, timing-dependent, fragile
[Fact]
public void Feature_UpdatesOnConfigChange_WithDI()
{
var services = new ServiceCollection();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["MaxRetries"] = "3" })
.Build();
services.Configure<MyOptions>(config);
// ... more setup ...
var sp = services.BuildServiceProvider();
var feature = sp.GetRequiredService<IFeature>();
((IConfigurationRoot)config).Reload(); // may or may not fire synchronously
feature.CurrentValue.MaxRetries.ShouldBe(5); // flaky
}
// ✅ With the general overload — true unit test, synchronous, deterministic
[Fact]
public void Feature_UpdatesOnConfigChange_WithFakeMonitor()
{
var monitor = new FakeOptionsMonitor<MyOptions>(new() { MaxRetries = 3 });
var services = new ServiceCollection();
services.AddFeature("test", (builder, ctx) =>
{
ctx.EnableFeature(monitor);
builder.Configure(monitor.CurrentValue);
});
var feature = services.BuildServiceProvider().GetRequiredService<IFeature>();
monitor.TriggerChange(new() { MaxRetries = 5 }); // synchronous, exact control
feature.CurrentValue.MaxRetries.ShouldBe(5); // always passes
}
The Fix: General Form First
Expose the general overload publicly. Make the DI-resolving version a thin wrapper over it:
// 1. The general form — real implementation, accepts anything
public void EnableFeature<TOptions>(IOptionsMonitor<TOptions> monitor, string? name = null)
{
if (monitor is null) throw new ArgumentNullException(nameof(monitor));
_context.EnableFeatureCore(monitor, name);
}
// 2. The convenience form — wrapper, not capability
public void EnableFeature<TOptions>(string? name = null)
=> EnableFeature(_serviceProvider.GetRequiredService<IOptionsMonitor<TOptions>>(), name);
Null guard placement: put it in the public method, not the internal one. When the caller passes null, they should see "monitor cannot be null" — not "optionsMonitor cannot be null" from an internal parameter name they never used.
RS0026: adding a second overload with optional parameters triggers the Roslyn analyzer warning "Do not add multiple public overloads with optional parameters." The two overloads have distinct mandatory parameters — no actual ambiguity. Suppress it with a pragma:
#pragma warning disable RS0026
public void EnableFeature<T>(IOptionsMonitor<T> monitor, string? name = null) { /* ... */ }
public void EnableFeature<T>(string? name = null) { /* ... */ }
#pragma warning restore RS0026
The FakeOptionsMonitor Test Helper
Once the general overload exists, a small helper unlocks deterministic testing across your entire test suite:
public sealed class FakeOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
{
private readonly List<Action<TOptions, string?>> _listeners = new();
public FakeOptionsMonitor(TOptions initialValue) => CurrentValue = initialValue;
public TOptions CurrentValue { get; private set; }
public TOptions Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<TOptions, string?> listener)
{
_listeners.Add(listener);
return new Reg(() => _listeners.Remove(listener));
}
/// <summary>Simulates a config change. Synchronous — no timing issues.</summary>
public void TriggerChange(TOptions newValue, string? name = null)
{
CurrentValue = newValue;
foreach (var listener in _listeners.ToList())
listener(newValue, name);
}
private sealed class Reg(Action onDispose) : IDisposable
{
public void Dispose() => onDispose();
}
}
No DI, no configuration stack, no timing uncertainty. TriggerChange fires synchronously, so your assertions run immediately after.
Before vs. After
| Scenario | DI-only API | General overload exposed |
|---|---|---|
| Standard IOptions / appsettings | ✓ Works | ✓ Still works |
| Feature flag SDKs | ✗ Forced DI registration | ✓ Pass directly |
| Remote config (push refresh) | ✗ Same forced registration | ✓ Instantiate, pass in |
| Multi-tenant (runtime discovery) | ✗ Can't register at startup | ✓ Create at runtime |
| Shared instance across consumers | △ DI singleton (implicit) | ✓ Explicit, guaranteed |
| Unit testing | ✗ Integration test required | ✓ FakeOptionsMonitor |
| Existing callers | — | ✓ No changes needed |
The fix is purely additive. Zero breaking changes.
The Real-World Case: Polly v8
This exact pattern appeared in Polly v8. AddResiliencePipelineContext<TKey>.EnableReloads<TOptions> only accepted DI-resolved monitors. The internal extension method ConfigureBuilderContextExtensions.EnableReloads already accepted a monitor directly. The public surface was narrower than the implementation.
The fix — add one overload, add a null guard, suppress RS0026, update the public API surface file, write two tests:
// New overload — Polly v8, App-vNext/Polly PR #3140
public void EnableReloads<TOptions>(IOptionsMonitor<TOptions> monitor, string? name = null)
{
Guard.NotNull(monitor);
RegistryContext.EnableReloads(monitor, name);
}
// Original overload — unchanged, all existing callers unaffected
public void EnableReloads<TOptions>(string? name = null)
=> RegistryContext.EnableReloads(
ServiceProvider.GetRequiredService<IOptionsMonitor<TOptions>>(),
name);
Three files changed. 111 existing tests continued to pass. All four blocked scenarios above were unblocked.
Principles for Library API Design
General form first. Write the parameterized implementation, then add convenience wrappers on top. Never let a wrapper become the only path to the capability it wraps.
Infrastructure is opt-in. DI, configuration, logging — these should be integration points, not prerequisites. A library feature shouldn't require a DI container to be usable.
If your method resolves, expose an overload that accepts. Any time a public method calls GetRequiredService<T>() and passes the result elsewhere, ask whether callers should be able to supply T directly. The answer is almost always yes.
Testability is correctness. If you can't test a feature without assembling infrastructure, the API surface is incorrectly coupled to its integration points.
Document the fix, not the workaround. "Register IOptionsMonitor as a singleton pointing to your custom implementation" is a workaround for a broken API surface. When the fix is one overload, add the overload.
Originally published on Medium.
Top comments (0)