Custom Strategies
Everything in Kevlar is a Strategy — middleware over an Outcome<T> pipeline. Retry, circuit breaker, timeout: all of them are implemented on the same surface you extend.
A logging strategy
public sealed class LoggingStrategy(ILogger logger) : Strategy
{
public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var start = context.TimeProvider.GetTimestamp();
var outcome = await next.InvokeAsync(context);
logger.LogInformation("{Shield} took {Elapsed}", context.ShieldName,
context.TimeProvider.GetElapsedTime(start));
return outcome;
}
}
var shield = Shield.Use(new LoggingStrategy(logger)).Retry(3);
Use slots your strategy into the chain at that position — here, outside the retries, so it logs total elapsed time across all attempts. Put it after Retry to log each attempt instead.
Override Describe() so shield.ToString() names your strategy meaningfully in pipeline descriptions:
public override string Describe() => "Logging";
protected override bool InvokesContinuationAtMostOnce => true;
The contract
Your strategy receives:
next— the rest of the pipeline (inner strategies, then the user's delegate). Invoke it withnext.InvokeAsync(context).context— theKevlarContextfor this execution.
And returns an Outcome<T>: success-with-result or failure-with-exception, as a struct.
next.InvokeAsync(context) preserves the caller-supplied state and the same context, including
its current name, time provider, cancellation token, and properties. Synchronous throws and
asynchronous faults from inner strategies or the user's delegate are normalized to failure
outcomes, so a valid continuation does not throw. A default, uninitialized
Continuation<T, TState> returns an InvalidOperationException outcome.
The power is in how many times you call next:
Calls to next | You've built a | Examples in the box |
|---|---|---|
| zero | short-circuit | circuit breaker (open), rate limit, concurrency limit rejection |
| one | decorator | timeout, fallback, logging, metrics |
| many | repeater | retry, hedging |
Override InvokesContinuationAtMostOnce with true only when every execution path calls next
zero or one time. This lets consumers such as the gRPC interceptors safely expose response headers
before the response completes. Keep the conservative false default for retry, hedging, loops, or
any strategy that may call next more than once.
Failures are outcomes, not throws
Strategies return failures as Outcome<T> values rather than throwing, so outer strategies can react to them cheaply:
public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var outcome = await next.InvokeAsync(context);
if (!outcome.IsSuccess)
{
// inspect outcome.Exception, decide what to do:
// return outcome unchanged, replace it, or try next again
}
return outcome;
}
The exception is only thrown once — at the pipeline boundary, back in the caller's frame, with its original stack trace intact.
Consume handling clauses
Reactive custom strategies should use the shield's active handling clause instead of hard-coding exception filters. Pass a factory to Use; Kevlar invokes it once and supplies a HandlingClause that wraps the current When/Or clause, or HandlingClause.Default when none is active:
public sealed class RetryOnceStrategy(HandlingClause handling) : Strategy
{
protected override HandlingClause? Handling => handling;
public override async ValueTask<Outcome<T>> ExecuteAsync<T, TState>(
Continuation<T, TState> next, KevlarContext context)
{
var strategyIndex = context.StrategyIndex;
var outcome = await next.InvokeAsync(context);
return handling.ShouldHandle(
in outcome,
context,
attemptNumber: 0,
strategyIndex: strategyIndex)
? await next.InvokeAsync(context)
: outcome;
}
}
var shield = Shield
.When<HttpRequestException>()
.Use(clause => new RetryOnceStrategy(clause));
ShouldHandle works with exception and typed-result outcomes. Pass the active context, attempt,
and the strategy index captured before invoking next to support context-aware clauses. The default handles ordinary
exceptions, excluding cancellation, Kevlar's fail-fast rejections, and fatal runtime failures. The
existing Use(Strategy) overload remains the simpler choice for proactive strategies that do not
inspect failures.
Override Strategy.Handling when retaining the supplied clause, as above. This declaration lets Kevlar's unreachable-fallback validation and Kevlar.Testing custom strategy descriptors see the strategy's handling. A strategy with intentionally local rules may ignore the factory argument and implement those rules itself.
Context properties
KevlarContext.Properties is isolated per execution. A KevlarKey<T> is identified by both
its case-sensitive name and T: keys with the same name and different value types do not
collide, while new key instances with the same name and type address the same value. Empty names
are valid. Stored null is present and distinct from a missing key, so TryGet returns true
with a null value and GetOrDefault does not substitute its fallback.
Callers can seed this bag with ExecuteWithContextAsync or ExecuteWithContext. The initializer
runs before the outermost strategy, retries reuse the logical context, and hedged attempts receive
detached property snapshots. See Executing.
KevlarContext
The context flows through the whole pipeline:
context.ShieldName— set viaWithName, for logs and metrics.context.TimeProvider— always use this instead ofDateTime/Stopwatch/Task.Delay, so your strategy stays testable withFakeTimeProviderlike the built-ins.context.CancellationToken— the current token. Strategies such as timeouts replace this for the layers beneath them — which is why delegates must use the token they're handed rather than a captured one.context.IsSynchronous—trueunderExecute; branch on it if your strategy would otherwise block or break a sync caller (hedging throws for sync callers this way).context.StrategyIndex— the current strategy's zero-based pipeline position. Nested execution restores the outer index before its strategy resumes, and hedge forks preserve the inner position.context.Properties— a typed property bag:Set(key, value),TryGet(key, out value),GetOrDefault(key), keyed byKevlarKey<T>:
static readonly KevlarKey<string> TenantId = new("tenant-id");
context.Properties.Set(TenantId, "acme");
if (context.Properties.TryGet(TenantId, out var tenant)) { /* ... */ }
Contexts are pooled and recycled by the engine — never store one beyond the execution.
The continuation also belongs to that execution; invoke it only while ExecuteAsync is running.
Thread safety
One strategy instance is shared by every execution of the shield containing it — and by every shield it's composed into. That's the state-sharing rule working in your favour, but it means your strategy must be thread-safe, like the built-in breakers and limiters.
Stateless strategies can be shared directly. Stateful strategies must synchronize their own
mutable fields. Per-execution data belongs in local variables or KevlarContext.Properties, not
in strategy instance fields.