Dependency Injection
The Kevlar.Extensions.DependencyInjection package registers named shields with Microsoft.Extensions.DependencyInjection and exposes them through a registry and as keyed services.
dotnet add package Kevlar.Extensions.DependencyInjection
Registering shields
using Kevlar;
using Kevlar.Extensions.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
// A shield instance, under a name:
services.AddShield("github",
Shield.Timeout(TimeSpan.FromSeconds(10)).Retry(3));
// A typed shield, built from the service provider (loggers, config, etc.):
services.AddShield<HttpResponseMessage>("downstream",
sp => HttpShield.WhenTransient().Retry(3).WithName("downstream"));
Because shields are immutable and thread-safe, each named shield is a singleton — which is exactly what you want: every consumer of "github" shares the same instance, and therefore the same circuit breaker state, rate-limit bucket and concurrency limit slots.
Registration extensions live in Microsoft.Extensions.DependencyInjection, which ASP.NET Core
projects import implicitly. Import Kevlar.Extensions.DependencyInjection only when using runtime
types such as IKevlarRegistry or IShieldProvider.
Consuming via the registry
using Kevlar.Extensions.DependencyInjection;
public sealed class GitHubClient(IKevlarRegistry registry)
{
private readonly Shield _shield = registry.GetShield("github");
public Task<User> GetUserAsync(string id, CancellationToken ct) =>
_shield.ExecuteAsync(ct2 => FetchUserAsync(id, ct2), ct).AsTask();
private static Task<User> FetchUserAsync(string id, CancellationToken ct) =>
Task.FromResult(new User());
}
Typed shields come back through GetShield<T>(name):
var httpShield = registry.GetShield<HttpResponseMessage>("downstream");
GetShield throws a KeyNotFoundException (with an actionable message) for unknown names; TryGetShield(name, out var shield) / TryGetShield<T>(...) are the non-throwing forms.
Registry semantics worth knowing:
- Shield names are unique across untyped and result-aware registrations. A duplicate throws during
registration. Pass
replace: trueexplicitly when replacement is intentional. - Ordinary factory registrations run lazily on first resolve and the result is cached — so every consumer shares one instance (and its strategy state), exactly like instance registrations.
- A factory exception is not cached. Concurrent callers observing one failed attempt receive that failure; the next resolve retries the factory and caches the first successful result.
AddShieldregisters theIKevlarRegistryfor you (AddKevlar()exists if you ever need just the registry).
Use AddPartitionedShield when one registration must retain
independent shield state per tenant, endpoint, or other key while remaining bounded.
Dynamic shields
Use GetOrAdd for names discovered after the service provider is built. One factory runs for each
name and result type, even under concurrent first access. Factory failures are not cached, so a
later call can recover. TryAdd installs a lazy factory only when the name is free; Remove
forgets the registration without disposing a shield already held by a caller.
using Kevlar;
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using var dynamicServices = new ServiceCollection().AddKevlar().BuildServiceProvider();
var dynamicRegistry = dynamicServices.GetRequiredService<IKevlarRegistry>();
var tenantShield = dynamicRegistry.GetOrAdd(
"tenant-north",
static _ => Shield.Retry(3));
if (!dynamicRegistry.TryAdd("plugin", static _ => Shield.Timeout(TimeSpan.FromSeconds(5))))
{
throw new InvalidOperationException("The plugin shield was already registered.");
}
Dynamic names are registry-only: Microsoft DI's keyed-service table is fixed when the provider is
built. Register with AddShield before build when [FromKeyedServices] resolution is required.
The registry is thread-safe and is disposed with the service provider. Disposal rejects later
lookups and disposes resolved strategies that implement IDisposable/IAsyncDisposable.
Binding shields from configuration
AddShield(name, IConfiguration) builds a shield from a configuration section, so retry counts,
timeouts and breaker thresholds are tunable per environment without a redeploy. Use
AddShield<TResult>(name, configuration) when consumers need a result-aware shield:
The Generic Host already loads appsettings.json. A standalone application can add the same JSON provider explicitly:
dotnet add package Microsoft.Extensions.Configuration.Json
// appsettings.json
{
"Resilience": {
"GitHub": {
"Timeout": "00:00:30",
"Retry": { "MaxRetries": 5, "Backoff": "Exponential", "MaxDelay": "00:00:10" },
"CircuitBreaker": { "FailureRatio": 0.5, "BreakDuration": "00:00:15" },
"AttemptTimeout": "00:00:05"
}
}
}
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json")
.Build();
services.AddShield("github", configuration.GetSection("Resilience:GitHub"));
The schema is ShieldDefinition. ShieldDefinition.Build() always chains the sections it finds in one fixed order, outermost first:
Timeout → Retry or Hedge → CircuitBreaker → RateLimit → ConcurrencyLimit → AttemptTimeout
Read that the same way as any fluent chain: Timeout is the total budget wrapping everything, retries or hedges happen inside it, each attempt passes through the breaker and the two limiters, and AttemptTimeout is the innermost per-attempt budget. Only the sections you declare are added; the remaining ones keep their relative order. The defaults inside each section match the fluent API's.
Configuration cannot reorder that chain — the order is what makes a definition readable across environments. Build the shield with the fluent API and register the instance when you need a different shape.
Configuring hedging
Use Hedge instead of Retry to launch concurrent attempts. For example, this configuration allows
two additional attempts, staggered by 100 milliseconds, within a 10-second total timeout:
{
"Timeout": "00:00:10",
"Hedge": {
"MaxHedgedAttempts": 2,
"Delay": "00:00:00.100"
},
"AttemptTimeout": "00:00:02"
}
HedgeDefinition defaults to one additional attempt and a one-second delay. An empty Hedge object
enables these defaults; omit the section to disable hedging. Scalar section values are invalid. Zero delay removes
timer staggering; any negative delay hedges only on failure. Hedging requires asynchronous execution
and an operation that is safe to invoke concurrently. Register the section with either
AddShield(name, configuration) or AddShield<TResult>(name, configuration).
Setting both Retry and Hedge throws KevlarConfigurationException when the definition is built.
Use the fluent API when you intentionally need both strategies. Fallback also requires the fluent API
because its replacement operation is a delegate and cannot be represented in configuration.
AddReloadingShield and AddReloadingShield<TResult> pick up changes to Hedge using the same
ReloadingShieldOptions as other sections. An invalid reload, including one that sets both Retry
and Hedge, keeps the last valid shield and reports the configuration error through the failure callback.
Validating shields at host startup
Registrations remain lazy by default. Call AddKevlarValidationOnStart() before building a Generic
Host to construct all explicitly registered named shields during StartAsync, before hosted services
start processing work:
using Kevlar;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder();
builder.Services.AddShield("orders", Shield.Retry(3, Backoff.None));
builder.Services.AddShield<int>("inventory", Shield.For<int>().Timeout(TimeSpan.FromSeconds(2)));
builder.Services.AddKevlarValidationOnStart();
using var host = builder.Build();
await host.StartAsync();
await host.StopAsync();
This uses the existing Options startup validation hook and requires Microsoft.Extensions.Hosting
8.0 or later. It works with every target of Kevlar.Extensions.DependencyInjection and adds no
hosting dependency to the core or DI package. Install the hosting package in the application, or
use the ASP.NET Core shared framework. The Web API sample
enables validation and exercises startup in its smoke mode.
Validation covers typed and untyped AddShield registrations and the initial publication of
AddReloadingShield, including named-options registrations. A factory or configuration failure
throws KevlarConfigurationException with the shield name, result type when applicable, and original
error as its inner exception. Validation stops at the first failed shield. It never executes a
protected operation. Successful construction populates the same cache used by normal resolution;
the registry retains ownership and disposes resources with the host, including after failed startup.
Later invalid reloads still keep the last valid publication.
The opt-in can appear before or after shield registrations and repeated calls are safe. Only registrations present when the service provider is built are included. Runtime registry additions are outside this validation set, removed registrations are skipped, and partition factories are not called because their key space may be unbounded. Validate application-specific partition keys explicitly when required. Ordinary host rules still apply: a factory cannot resolve a scoped service from the root provider when scope validation is enabled.
Reloading configuration atomically
AddShield(name, IConfiguration) intentionally binds once, when first resolved. Use
AddReloadingShield when future configuration reloads must affect new operations:
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
services.AddReloadingShield(
"github",
configuration.GetSection("Resilience:GitHub"),
error => logger.LogError(error, "Rejected GitHub shield configuration"));
Consume the keyed provider and read Current once per operation:
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
public sealed class GitHubClient(
[FromKeyedServices("github")] IShieldProvider provider)
{
public Task<User> GetUserAsync(string id, CancellationToken ct)
{
Shield snapshot = provider.Current;
return snapshot.ExecuteAsync(
ct2 => FetchUserAsync(id, ct2),
ct).AsTask();
}
private static Task<User> FetchUserAsync(string id, CancellationToken ct) =>
Task.FromResult(new User());
}
For a reloading name, registry.GetShield("github") returns a stable live-forwarding shield. It is
safe to retain: each execution uses one current last-known-good snapshot, while ToString() and
Kevlar.Testing's GetDescriptor() describe the current snapshot. The registry returns the same
forwarding shield on later lookups.
Reloading names intentionally do not register a keyed Shield. Resolve the keyed
IShieldProvider when explicit snapshot semantics are useful, and read Current once per
operation as above. Every ordinary AddShield registration exposes both a keyed shield and an
IShieldProvider, whose Current snapshot remains fixed.
The generic forms are symmetric: AddReloadingShield<TResult> publishes through
IShieldProvider<TResult> and a live-forwarding registry.GetShield<TResult>(name). It likewise
omits a keyed Shield<TResult> so consumers cannot accidentally retain a stale snapshot.
Changes are debounced for 250 milliseconds by default, coalescing file-watcher bursts into one
rebuild. Pass a ReloadingShieldOptions instance to customize DebounceDelay or TimeProvider.
On a valid change, Kevlar builds the entire replacement before one atomic publish. Operations
already using the prior snapshot finish on it. Invalid configuration keeps the last known-good
snapshot and invokes the optional failure callback; callback exceptions are contained so later
reloads remain active. Each successful replacement starts with fresh circuit-breaker,
rate-limiter, and concurrency-limiter state. The provider performs no binding, locking, or
allocation while reading Current; all rebuild work occurs on configuration change. Disposing
the service provider removes the change-token subscription.
Named options can drive the same atomic replacement model without the fixed ShieldDefinition
schema. The options name and shield name are the same; a change for another name is ignored.
using Kevlar;
using Kevlar.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
public sealed class CatalogResilienceOptions
{
public int MaxRetries { get; set; } = 3;
}
services.AddReloadingShield<CatalogResilienceOptions>(
"catalog",
static (options, _) => Shield.Retry(options.MaxRetries),
error => Console.Error.WriteLine(error.Message));
Resolve IOptionsMonitor<CatalogResilienceOptions> through normal Microsoft options registration.
Successful changes publish a fresh shield; build or validation failures retain the prior snapshot
and reach the failure callback. Use AddReloadingShield<TOptions, TResult> for typed shields.
Consuming as a keyed service
Named shields are also registered as keyed services, so you can skip the registry entirely:
using Microsoft.Extensions.DependencyInjection;
public sealed class GitHubClient([FromKeyedServices("github")] Shield shield)
{
public Shield Resilience { get; } = shield;
}
Naming shields
WithName stamps a name onto the shield itself, which then shows up as KevlarContext.ShieldName in custom strategies and callbacks — useful for logging and metrics:
var namedShield = Shield.Retry(3).WithName("github");
AddShield("github", …) registers under that DI name either way; WithName is about observability inside the pipeline.
For HttpClient pipelines specifically, see the HTTP integration — it builds on this package's registration model.
Shared retry budgets
Register a budget once, then reference its name from RetryDefinition.Budget or
HedgeDefinition.Budget. Typed, untyped, and reloading registrations resolve the same keyed
singleton, so configuration reloads preserve the shared balance:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddRetryBudget("inventory", maxTokens: 100, tokenRatio: 0.1);
var configuration = new ConfigurationBuilder().AddInMemoryCollection(
new Dictionary<string, string?>
{
["Retry:MaxRetries"] = "3",
["Retry:Budget"] = "inventory",
}).Build();
services.AddShield("inventory-client", configuration);
services.AddRetryBudget(name, existingBudget) registers an existing instance. Duplicate names
are rejected. Resolve it with GetRequiredKeyedService<RetryBudget>(name) to inspect Tokens.
For a manually constructed definition, call definition.Build(serviceProvider) to resolve names;
Build() reports a configuration error when a named budget is requested without a provider.
Unknown names also produce a configuration error. Budget settings are immutable: registering a
new budget is an explicit choice, independent of shield configuration reloads.
Register a replenishing allowance through the existing instance overload:
using Microsoft.Extensions.DependencyInjection;
using Kevlar.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddRetryBudget("inventory", RetryBudget.CreateReplenishing(
maxTokens: 100, replenishmentPeriod: TimeSpan.FromSeconds(10)));
All shields referencing inventory share that allowance, including different partition keys when
their factory uses the registered instance. Register separate instances for independent allowances.
The budget owns its clock; shield configuration reloads neither reset its balance nor move its
window boundaries. See additional-attempt accounting.