Behavioral.Chain.ActionChain
ActionChain<TCtx> is a tiny, middleware-style pipeline for “branchless rule packs.”
Each step receives the current context and a next delegate; it can continue or short-circuit.
Use it when you want ordered rules (logging, validation, pre-auth, pricing, etc.) without if ladders, and when you
need very explicit continue/stop semantics.
TL;DR
using PatternKit.Behavioral.Chain;
var log = new List<string>();
var chain = ActionChain<HttpRequest>.Create()
.When((in r) => r.Headers.ContainsKey("X-Request-Id"))
.ThenContinue(r => log.Add($"reqid={r.Headers["X-Request-Id"]}"))
.When((in r) => r.Path.StartsWith("/admin", StringComparison.Ordinal) &&
!r.Headers.ContainsKey("Authorization"))
.ThenStop(r => log.Add("deny: missing auth"))
// Tail runs only if earlier steps called `next`
.Finally((in r, next) =>
{
log.Add($"{r.Method} {r.Path}");
next(in r); // terminal `next` is a no-op
})
.Build();
chain.Execute(new HttpRequest("GET", "/health", new Dictionary<string,string>()));
chain.Execute(new HttpRequest("GET", "/admin/metrics", new Dictionary<string,string>()));
// => ["GET /health", "deny: missing auth", "GET /admin/metrics"]
Why ActionChain?
- Linear, readable rule packs: each rule says when it applies and what it does.
- Strict stop by default: if a handler doesn’t call
next, the chain ends immediately. - Low overhead: builds a single, composed delegate;
Executeis just one call. - Perf-shaped:
inparameters avoid copies; no LINQ; minimal allocations afterBuild().
Core API
public sealed class ActionChain<TCtx>
{
public delegate void Next(in TCtx ctx);
public delegate void Handler(in TCtx ctx, Next next);
public delegate bool Predicate(in TCtx ctx);
public void Execute(in TCtx ctx);
public static Builder Create();
public sealed class Builder
{
// Always-run middleware (if continued)
public Builder Use(Handler handler);
// Conditional block
public WhenBuilder When(Predicate predicate);
// Tail handler (runs only if chain wasn’t short-circuited)
public Builder Finally(Handler tail);
public ActionChain<TCtx> Build();
}
public sealed class WhenBuilder
{
// Run custom handler when predicate is true; else continue automatically
public Builder Do(Handler handler);
// Run action and STOP the chain when predicate is true
public Builder ThenStop(Action<TCtx> action);
// Run action and CONTINUE when predicate is true
public Builder ThenContinue(Action<TCtx> action);
}
}
Semantics (important!)
Strict stop: Any handler can end the chain by not calling
next. This also skipsFinally. If you truly need “always run,” split logging into a separate chain or ensure every earlier step callsnext.Ordering matters: handlers run in the order you register them.
When(...).ThenContinuevsThenStop:ThenContinueexecutes the action and always callsnext.ThenStopexecutes the action and never callsnext.
Patterns you’ll use
Auth gate + logging (strict stop):
var chain = ActionChain<HttpRequest>.Create() .When(static (in r) => r.Path.StartsWith("/admin") && !r.Headers.ContainsKey("Authorization")) .ThenStop(r => audit.Deny(r)) // stop: no tail .Finally((in r, next) => { audit.Seen(r); next(in r); }) .Build();Pre-authorization checks (multiple early exits):
var chain = ActionChain<Cart>.Create() .When(static (in c) => c.Items.Count == 0) .ThenStop(c => c.Fail("empty-basket")) .When(static (in c) => c.CustomerAge < 21 && c.Items.Any(i => i.AgeRestricted)) .ThenStop(c => c.Fail("age")) .Finally((in c, next) => { c.Pass("preauth-ok"); next(in c); }) .Build();Branchless rule packs (totals/discounts):
var totals = ActionChain<Tx>.Create() .Use(static (in c, next) => { c.RecomputeSubtotal(); next(in c); }) .When(static (in c) => c.FirstTenderIsCash).ThenContinue(c => c.AddDiscount(0.02m, "cash")) .When(static (in c) => c.HasLoyalty).ThenContinue(c => c.AddDiscount(0.05m, "loyalty")) .Finally(static (in c, next) => { c.ComputeTax(); next(in c); }) .Build();
Testing with TinyBDD (spec-style)
await Given("a chain that denies /admin without auth", () =>
{
var log = new List<string>();
var chain = ActionChain<HttpRequest>.Create()
.When((in r) => r.Path.StartsWith("/admin") &&
!r.Headers.ContainsKey("Authorization"))
.ThenStop(r => log.Add("deny"))
.Finally((in r, next) => { log.Add($"{r.Method} {r.Path}"); next(in r); })
.Build();
return (chain, log);
})
.When("GET /admin no auth", s => { s.chain.Execute(new("GET","/admin",new Dictionary<string,string>())); return s; })
.Then("first line is deny", s => s.log[0] == "deny")
.And("no tail logged (strict stop)", s => s.log.Count == 1)
.AssertPassed();
Tips & gotchas
- Want tail to always run? Don’t use
ThenStopearlier, or move the “always-run” logic to a separate chain invoked after this one. - Avoid captures: copy locals (
var pred = _pred;) like the builder does, to keep delegates non-allocating. - Use
ineverywhere: it keeps hot-path costs low for structs and larger contexts. - Compose freely: you can wrap chains into stages (e.g., a transaction pipeline), or put chains behind higher-level builders.
See also
- ResultChain – like ActionChain, but steps return a result and short-circuit on failure.
- BranchBuilder – zero-
ifrouter (predicate → step) for first-match-wins dispatch. - Strategy / TryStrategy – single-choice or first-success selection for handlers/parsers.