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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SD-Sty.MethodChain" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SD-Sty.MethodChain" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="SD-Sty.MethodChain" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SD-Sty.MethodChain --version 1.0.0
                    
#r "nuget: SD-Sty.MethodChain, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SD-Sty.MethodChain@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SD-Sty.MethodChain&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=SD-Sty.MethodChain&version=1.0.0
                    
Install as a Cake Tool

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

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 before RunAsync returns. None/AnyOne only 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 stored List<Order>, etc.
  • A typed run throws InvalidOperationException if the chain set no result, or set one of the wrong type — it fails loud instead of silently returning default.
  • Value-type caveat: RunAsync<int> returns the int a method set and throws if none did. If a method may legitimately produce null, use RunAsync<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 one AggregateException at the end of the run.
[MethodChain("order", 1, BreakOnException = false)]   // keep going, aggregate at the end
public Task ChargeCard() => ...;

BreakOnException is 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 (three ConcurrentDictionary instances + 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 every Set/Get is a case-insensitive ConcurrentDictionary hit — string hashing, not a register/stack argument pass.
  • Boxing: value-type inputs/outputs are stored as object?, so they box on Set/SetResult and unbox on Get/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/Wait must match within a stage (MC0002). BreakOnException must 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 IChainContext calls 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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