SD-Sty.MethodChain
1.0.0
dotnet add package SD-Sty.MethodChain --version 1.0.0
NuGet\Install-Package SD-Sty.MethodChain -Version 1.0.0
<PackageReference Include="SD-Sty.MethodChain" Version="1.0.0" />
<PackageVersion Include="SD-Sty.MethodChain" Version="1.0.0" />
<PackageReference Include="SD-Sty.MethodChain" />
paket add SD-Sty.MethodChain --version 1.0.0
#r "nuget: SD-Sty.MethodChain, 1.0.0"
#:package SD-Sty.MethodChain@1.0.0
#addin nuget:?package=SD-Sty.MethodChain&version=1.0.0
#tool nuget:?package=SD-Sty.MethodChain&version=1.0.0
MethodChain
Declarative, source-generated method pipelines for .NET. Annotate ordinary methods with [MethodChain], give them a chain id and a stage number, and MethodChain wires them into an ordered pipeline that flows a shared context between them — with per-stage concurrency, typed results, and build-time validation.
Table of contents
- Why MethodChain
- Install
- Core concepts
- Defining chains
- The shared context
- Execution model
- Running a chain & results
- Exception handling (
BreakOnException) - Dependency injection
- Build-time diagnostics
- Overhead and limitations
- License
Why MethodChain
A lot of application logic is a pipeline: validate → enrich → call services → persist → notify. Expressing that as a hand-written orchestrator means boilerplate for ordering, passing data between steps, running independent steps concurrently, and handling failures. MethodChain lets you:
- Keep the steps as plain methods on one or more classes testable in isolation.
- Declare order and concurrency with an attribute instead of wiring code.
- Flow data by name through a shared context rather than threading parameters/return values manually.
- Pay no reflection cost at run time — the generator emits a runner that calls your methods directly.
Install
- Requires .NET 6.0 or later.
dotnet add package SD-Sty.MethodChain
Core concepts
| Term | Meaning |
|---|---|
| Chain | All [MethodChain] methods sharing a ChainId, run as one pipeline. May span several classes. |
| Stage | An integer ordering key. Lower stages run first, methods sharing a stage form a group. |
| Host | A class containing chain methods. One class can host several chains, one chain can be split across classes. |
| Context | An IChainContext flowing through the chain — by-name inputs/outputs plus a typed result slot. |
| By-name binding | A method parameter (other than IChainContext/CancellationToken) is supplied by matching name from the seed inputs or values published with ctx.Set(...). |
Defining chains
Tag each step with [MethodChain(chainId, stage)]. Steps must be internal or public (see diagnostics) and return void, Task, Task<T>, ValueTask, or ValueTask<T>. Read inputs as by-name parameters, inject IChainContext to publish values or the result.
public class Checkout
{
[MethodChain("checkout", 1)]
public void Validate(Order order, IChainContext ctx) // `order` is bound by name from the seed
{
if (order.Products.Count == 0) throw new InvalidOperationException("empty cart");
ctx.Set("total", order.Total); // publish for later stages
}
[MethodChain("checkout", 2)]
public async Task Authorize(decimal total, IChainContext ctx) // `total` is bound from the "total" value
{
Guid paymentId = await _payments.Charge(total);
ctx.SetResult(paymentId); // the chain's response
}
}
Split a chain across classes
Methods that share a ChainId form one chain even across classes — pass one instance per declaring class:
public class CustomerService
{
[MethodChain("order", 1)]
public void LookUp(OrderRequest request, IChainContext ctx) =>
ctx.Set("email", _repo.EmailFor(request.CustomerId));
}
public class ShipmentService
{
[MethodChain("order", 2)]
public void Ship(string email, IChainContext ctx) => // `email` came from stage 1
ctx.SetResult(_carrier.CreateLabel(email));
}
Guid label = await MethodChainRunner.RunAsync<Guid>(
new object[] { new CustomerService(), new ShipmentService() }, "order", new { request });
Host multiple chains in one class
One class can host as many chains as you like — one per ChainId:
public class Billing
{
[MethodChain("invoice", 1)] public void Build(IChainContext ctx) => ctx.SetResult(_invoices.Build());
[MethodChain("refund", 1)] public void Make(IChainContext ctx) => ctx.SetResult(_refunds.Make());
}
Invoice invoice = await MethodChainRunner.RunAsync<Invoice>(new Billing(), "invoice");
Refund refund = await MethodChainRunner.RunAsync<Refund>(new Billing(), "refund");
The shared context
IChainContext is injected by declaring an IChainContext parameter. It carries two things.
Named values — published for downstream methods and bound to their by-name parameters. Use until: to expire a value after a given stage:
[MethodChain("cart", 1)]
public void Price(Cart cart, IChainContext ctx)
{
ctx.Set("subtotal", cart.Sum());
ctx.Set("coupon", cart.Coupon, until: 2); // live through stage 2, then purged
}
[MethodChain("cart", 2)]
public void ApplyDiscount(decimal subtotal, IChainContext ctx) // `subtotal` bound by name
{
if (ctx.TryGet<string>("coupon", out _)) // or get the value from IChainContext
ctx.Update<decimal>("subtotal", s => s * 0.9m); // atomic read-modify-write under a per-name lock
}
The result slot — the value a typed run returns. It's written here and read back by the caller (see Running a chain & results):
ctx.SetResult(receipt); // last writer wins
ctx.UpdateResult<decimal>(t => t + amount); // atomic accumulate into the result
A method that writes context (
Set/SetResult/Update/UpdateResult) marks its stage group as a producer: a Concurrent/Parallel group that writes context is automatically awaited (Wait = All) before the chain advances, so a downstream stage never reads a half-produced value.
Execution model (Mode & Wait)
Steps can be synchronous (void) or asynchronous (Task/ValueTask), mixed freely within a chain. Each stage group then runs according to the Mode and Wait declared on its methods (which must be consistent within the stage):
ExecutionMode — how the methods in a stage run relative to each other:
| Mode | Behavior |
|---|---|
Sequential (default) |
One after another, in order. |
Concurrent |
Cooperatively on one thread — async bodies overlap at their awaits. |
Parallel |
On the thread pool, even for synchronous bodies. Cap with MaxDegreeOfParallelism. |
WaitMode — when the runner advances past a Concurrent/Parallel group:
| Wait | Behavior |
|---|---|
None (default) |
Start the group and advance immediately. Any unfinished methods complete before the chain returns. |
AnyOne |
Advance once any one method completes. Any unfinished methods complete before the chain returns. |
All |
Advance only after every method in the group completes. |
With
Wait = None, a fire-and-forget method's effect is achieved but all outstanding tasks are awaited beforeRunAsyncreturns.None/AnyOneonly change mid-chain visibility/timing.
Running a chain & results
Run a chain with an instance in hand. Inputs are seeded by name. Run/Run<T> are synchronous wrappers over the async runners. To run a chain by id with hosts resolved from a DI container, see
Dependency injection.
// `checkout` produces a result, so read it with a typed Run<TResult>/RunAsync<TResult> overload.
// Inputs are seeded from an anonymous/DTO object (flattened by property name)…
Guid paymentId = await MethodChainRunner.RunAsync<Guid>(new Checkout(), "checkout", new { order });
// …or from explicit name/value pairs.
await MethodChainRunner.RunAsync<Guid>(new Checkout(), "checkout", ("order", order));
// A chain that sets no result is run untyped.
await MethodChainRunner.RunAsync(new Notifier(), "notify", new { order });
// Run / Run<T> are synchronous wrappers over the async runners.
MethodChainRunner.Run<Guid>(new Checkout(), "checkout", new { order });
A method returns a value from the chain by publishing it with ctx.SetResult(...) (see The shared context). The caller reads it with a typed overload:
- Last writer wins if several methods set a result.
- Covariance/collections work:
RunAsync<IEnumerable<Order>>reads a storedList<Order>, etc. - A typed run throws
InvalidOperationExceptionif the chain set no result, or set one of the wrong type — it fails loud instead of silently returningdefault. - Value-type caveat:
RunAsync<int>returns theinta method set and throws if none did. If a method may legitimately producenull, useRunAsync<int?>.
Cancellation
Pass a CancellationToken to RunAsync. It's observed between stage groups and can be injected into any method by declaring a CancellationToken parameter:
[MethodChain("report", 1)]
public async Task Generate(IChainContext ctx, CancellationToken ct) =>
await _db.QueryAsync(ct);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await MethodChainRunner.RunAsync(new Reports(), "report", input: null, cancellationToken: cts.Token);
Exception handling (BreakOnException)
BreakOnException (on the attribute, default true) controls what happens when a method throws:
true(fail fast) — the original exception propagates as-is and the chain does not proceed to the next stage. A Sequential stage halts at the throwing method. An awaited Concurrent/Parallel group lets its already-started siblings finish, then rethrows the first exception.false— every method still runs. Exceptions are collected and thrown together as oneAggregateExceptionat the end of the run.
[MethodChain("order", 1, BreakOnException = false)] // keep going, aggregate at the end
public Task ChargeCard() => ...;
BreakOnExceptionis chain-wide: every method in a chain must declare the same value (a mismatch is build error MC0006). Fire-and-forget (Wait = None/AnyOne) exceptions can only surface at the end of chain, so fail-fast cannot abort those mid-chain.
Dependency injection
Run a chain by id alone, with hosts resolved from the container, by calling AddMethodChain() and injecting IMethodChainRunner:
services.AddScoped<IOrderRepository, OrderRepository>(); // your hosts' dependencies
services.AddMethodChain(); // discovers + scoped-registers every chain host
// elsewhere (controller/service)
public class OrdersController
{
private readonly IMethodChainRunner _runner;
public OrdersController(IMethodChainRunner runner) { _runner = runner; }
public Task<Guid> Place(OrderRequest request) =>
_runner.RunAsync<Guid>("order", new { request });
}
AddMethodChain() auto-registers every generated chain host as scoped, a host you pre-registered with any lifetime wins.
Build-time diagnostics
| Id | Severity | Fires when |
|---|---|---|
MC0001 |
Error | A chain method (or an enclosing type) is private/protected — it can't be reached by the generated runner. Make it internal/public. |
MC0002 |
Error | Methods in one stage mix Mode / MaxDegreeOfParallelism / Wait. |
MC0003 |
Error | A typed Run<T>("c") targets a chain that never calls SetResult/UpdateResult. |
MC0004 |
Error | A chain produces a result, but an untyped run ignores it (use Run<T>). |
MC0005 |
Error | A typed Run<T> requests a type the chain doesn't produce. |
MC0006 |
Error | Methods in one chain declare different BreakOnException values. |
Overhead and limitations
Overhead
⚠️ MethodChain is not a zero-cost abstraction over a method call. A chain run costs on the order of microseconds where a direct call costs nanoseconds — orders of magnitude more. The overhead is negligible when each step performs I/O (database, HTTP, or file operations), as they typically take much longer to complete than the orchestration itself. Avoid using chains in hot CPU paths or tight loops. They're intended for coarse-grained workflows, not fine-grained operations.
Where the cost comes from each run, versus a plain method call:
- Allocations: a
ChainContext(threeConcurrentDictionaryinstances + a lock), a per-run state object, the seed dictionary, and the task lists for any concurrent/parallel stages. - Dictionary lookups: every by-name parameter (
ctx.GetBound<T>("name")) and everySet/Getis a case-insensitiveConcurrentDictionaryhit — string hashing, not a register/stack argument pass. - Boxing: value-type inputs/outputs are stored as
object?, so they box onSet/SetResultand unbox onGet/GetBound. - Indirection: the chain is resolved by string id (a registry lookup), dispatched through an interface, and driven by an async state machine that allocates
Task— even for all-synchronous steps.
It is, however, cheap in the ways that matter for I/O-bound work:
- No per-run reflection. The generator emits a runner that calls your methods directly. Input property getters are compiled and cached on first use of each input type — a one-time cost, not per-run.
- No per-run discovery. The chain registry is wired up once via a
[ModuleInitializer]at load.
Limitations
- Generator-only execution. Chain methods must be
internal/public, on a non-abstract host, in a project that references the MethodChain package. - Consistency rules:
Mode/MaxDegreeOfParallelism/Waitmust match within a stage (MC0002).BreakOnExceptionmust match across the whole chain (MC0006). - Fire-and-forget timing: with
Wait = None/AnyOne, a value/result is guaranteed present by the time the run returns, but mid-chain visibility is not — and fail-fast can't abort those groups early. - Best-effort static analysis: the "writes context / produces a result" detection looks at direct
IChainContextcalls in the method body. A write funneled through a helper isn't seen. - Result slot: last-writer-wins. One slot per chain run.
License
MethodChain is licensed under the MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net6.0
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 114 | 6/30/2026 |