6 min readStart here

Policies are values, not pipelines

Stop building complex resilience pipelines. Learn how NResilience uses immutable records and `with` expressions to make resilience configuration simple and bug-free.

getting-startedpoliciesapi-design

TL;DR: A policy in NResilience is a plain C# value. Instead of building a pipeline where the order of layers can cause silent bugs, you define a policy as a record and use with expressions to derive variants.

The problem with pipelines

If you've used most resilience libraries, you're used to the "pipeline" or "strategy chain" mental model. You create a builder, add a retry policy, then a circuit breaker, then a timeout, and finally call .Build().

The problem is that in a pipeline, order is everything.

If you put the timeout on the outside, it's a deadline for the entire operation. If you put it on the inside, it's a timeout per attempt. Get it wrong, and you have a bug that only surfaces under load - like a 30-second timeout that actually takes 90 seconds because it's being retried three times.

NResilience throws the pipeline away.

Policies as values

In NResilience, a policy is a sealed partial record. It's a plain C# value.

There is no builder. There is no .Build() method. There is no "ordering" to get wrong because the executor's logic is fixed: it always respects the deadline first, then the attempt timeout, then the retry logic.

You don't "build" a policy; you just state your bounds.

using NResilience;

// This isn't a builder. It's just a record.
var policy = new Resilience
{
    Attempts = 3,
    Deadline = TimeSpan.FromSeconds(10),
    AttemptTimeout = TimeSpan.FromSeconds(2),
};

Starting with presets

You rarely need to define every property from scratch. The library provides presets that cover the most common scenarios.

  • Resilience.Default: The baseline. Three attempts, a 30s deadline, a 10s attempt timeout, and standard exponential backoff.
  • Resilience.Http: The Default policy, but configured with an HTTP-aware classifier. It knows that a 503 Service Unavailable should be retried, but a 404 Not Found is a final answer.
  • Resilience.None: Passthrough. One attempt, no bounds, no budget. It's the only configuration that is completely free - the executor returns the callback's own task with zero allocations.

Deriving variants with with

Because Resilience is a record, you can use the with expression to derive a new policy from an existing one. This is the configuration language of the library.

If you have a house policy for your API, but one specific call needs to be more patient, you don't rebuild the whole pipeline. You just derive a variant:

var apiPolicy = Resilience.Http;

// Deriving a "patient" variant
var patientPolicy = apiPolicy with 
{ 
    Deadline = TimeSpan.FromMinutes(1) 
};

// Deriving a "fast" variant from the patient one
var fastPolicy = patientPolicy with 
{ 
    Attempts = 1, 
    AttemptTimeout = TimeSpan.FromMilliseconds(250) 
};

with copies all other properties, so fastPolicy still inherits the HTTP classifier and the 1-minute deadline from patientPolicy.

The Policies class pattern

In a real app, you shouldn't create policies on the fly. Doing so can be expensive and, more importantly, it destroys the state of your circuit breakers and retry budgets.

The pattern is to use static readonly fields in a central Policies class. This makes your resilience configuration discoverable, immutable, and efficient.

public static class Policies
{
    // The base API policy
    public static readonly Resilience Api = (Resilience.Http with 
    { 
        Deadline = TimeSpan.FromSeconds(10),
        AttemptTimeout = TimeSpan.FromSeconds(3),
    }).Validated();

    // Realtime calls are a variant of the Api policy
    public static readonly Resilience Realtime = Api with 
    { 
        Attempts = 1, 
        AttemptTimeout = TimeSpan.FromMilliseconds(250), 
    };
}

Note the .Validated() call. Because records don't have a Build() method, validation happens lazily on the first call. Calling .Validated() explicitly ensures that any configuration errors (like an AttemptTimeout that is longer than the Deadline) throw immediately at startup rather than in the middle of a production request.

Equality semantics

Because policies are values, you can compare them using ==.

This seems trivial, but it's incredibly useful for telemetry and diagnostics. If you're logging which policy was used for a call, you don't have to rely on fragile string names. You can check if the policy used was exactly the Policies.Realtime instance.

Conclusion

By treating policies as values rather than pipelines, NResilience removes a whole class of "ordering" bugs. You stop worrying about where the timeout sits in the chain and start focusing on the actual bounds of your system.

Next, we'll look at how to handle the results of these calls without the "try-catch dance."

Failure without exceptions covers TryRunAsync and CallResult<T> - how to turn resilience outcomes into data you can branch on.