NxGraph 2.2.0-alpha

This is a prerelease version of NxGraph.
dotnet add package NxGraph --version 2.2.0-alpha
                    
NuGet\Install-Package NxGraph -Version 2.2.0-alpha
                    
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="NxGraph" Version="2.2.0-alpha" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NxGraph" Version="2.2.0-alpha" />
                    
Directory.Packages.props
<PackageReference Include="NxGraph" />
                    
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 NxGraph --version 2.2.0-alpha
                    
#r "nuget: NxGraph, 2.2.0-alpha"
                    
#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 NxGraph@2.2.0-alpha
                    
#: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=NxGraph&version=2.2.0-alpha&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=NxGraph&version=2.2.0-alpha&prerelease
                    
Install as a Cake Tool

NxGraph

A lean, high‑performance finite state machine (FSM) / stateflow library for .NET with a clean authoring DSL, strong validation, first‑class observability, and export tools (Mermaid, tracing, replay). Designed for correctness on hot paths (allocation‑free), production diagnostics, and pleasant authoring.


Why NxGraph

  • Simple, fast core: array‑backed Graph of Node[] and Transition[], single edge per node. Cache‑friendly and easy to reason about.
  • Explicit branching: choices/switches are modeled by director nodes, keeping the graph sparse and predictable.
  • Production diagnostics: validators, Mermaid exporter, tracing observers, and replay tooling.
  • Authoring ergonomics: fluent DSL with StartWith, .To(...), .If(...), .Switch(...), .WaitFor(...), .Timeout(...).

Features

  • ✅ Allocation‑aware execution (ValueTask<Result> hot paths)
  • ✅ Clear DSL for building graphs
  • ✅ Directors for branching (If, Switch, choice predicates)
  • ✅ Time primitives: WaitFor(TimeSpan), Timeout(TimeSpan) wrappers
  • ✅ Strong validation: broken edges, self‑loops, reachability, terminal paths
  • ✅ Observers: lifecycle + node/transition hooks; exceptions bubble by default
  • ✅ Mermaid exporter for architecture/ops visuals
  • ✅ Replay trace capture and deterministic playback
  • ✅ (Optional) OpenTelemetry‑style tracing via Activity
  • ✅ Serialization (JSON/MessagePack) for graphs and replays

Quick start

using System.Threading;
using System.Threading.Tasks;
using NxGraph;
using NxGraph.Authoring;

// 1) Define state logic (no allocations on hot path)
static ValueTask<Result> Acquire(CancellationToken ct) => ResultHelpers.Success;
static ValueTask<Result> Process(CancellationToken ct) => ResultHelpers.Success;
static ValueTask<Result> Release(CancellationToken ct) => ResultHelpers.Success;

// 2) Build the graph with the DSL
var graph = GraphBuilder
    .StartWith(Acquire)
    .To(Process)
    .To(Release)
    .Build();

// 3) Execute
var sm = graph.ToAsyncStateMachine();
await sm.ExecuteAsync(CancellationToken.None);

What you get

  • Deterministic, single‑edge execution
  • Easy branching via directors (see below)
  • Hooks for tracing/visualization

Core concepts

  • Graph: immutable structure of nodes and single outgoing transitions.
  • Node: wraps ILogic — work that returns a Result (Success, Failure, etc.).
  • Director: a special node that chooses the next node (e.g., If, Switch).
  • Transition: an index from node i → j.
  • State machine: a runtime over a Graph that executes from a start node until terminal.

Authoring DSL

Linear flows

static ValueTask<Result> Start(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> End(CancellationToken _) => ResultHelpers.Success;

var graph = GraphBuilder
    .StartWith(Start)
    .To(End)
    .Build();

Branching with directors

static ValueTask<Result> Start(CancellationToken _) => ResultHelpers.Success;
static bool IsPremium() => true; // your predicate
static ValueTask<Result> Premium(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> Standard(CancellationToken _) => ResultHelpers.Success;

var graph = GraphBuilder
    .StartWith(Start)
    .If(IsPremium)
        .Then(Premium)
        .Else(Standard)
    .Build();

Switch example:

static ValueTask<Result> Start(CancellationToken _) => ResultHelpers.Success;

static int RouteKey() => 2;
static ValueTask<Result> One(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> Two(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> Other(CancellationToken _) => ResultHelpers.Success;

var graph = GraphBuilder
    .StartWith(Start)
    .Switch(RouteKey)
        .Case(1, One)
        .Case(2, Two)
        .Default(Other)
    .End()
    .Build();

Delays & timeouts

Both runtimes have wait and timeout constructs; all timeout overloads take the timeout first.

// Sync (frame-stepped): WaitFor returns InProgress across ticks; ToWithTimeout checks the
// deadline between ticks and feeds the unified fault model on overrun.
var graph = GraphBuilder
    .StartWith(Start)
    .WaitFor(250.Milliseconds())
    .ToWithTimeout(500.Milliseconds(), () => Result.Success)
    .To(End)
    .Build();

// Async: WaitForAsync awaits a delay; ToWithTimeoutAsync cancels the wrapped logic on overrun.
var asyncGraph = GraphBuilder
    .StartWithAsync(StartAsync)
    .WaitForAsync(250.Milliseconds())
    .ToWithTimeoutAsync(500.Milliseconds(), async ct => await WorkAsync(ct))
    .Build();

The async timeout cancels the wrapped logic via CancellationToken — honor the token inside your logic for graceful stops. The sync timeout cannot interrupt a node mid-execution (no cancellation in the sync runtime); it detects the deadline between ticks. Under TimeoutBehavior.Fail (default) a timeout is an ordinary node failure routed through failure edges and retries.

Agents (dependency injection)

Provide an agent (context/service) to all nodes that opt‑in via IAgentSettable<T>.

public sealed class AppAgent { public required ILogger Log { get; init; } }

public sealed class WorkState : ILogic, IAgentSettable<AppAgent>
{
    private AppAgent _agent = default!;
    public void SetAgent(AppAgent agent) => _agent = agent;
    public ValueTask<Result> ExecuteAsync(CancellationToken ct)
    {
        _agent.Log.LogInformation("working");
        return ResultHelpers.Success;
    }
}

var g = GraphBuilder.StartWith(new WorkState()).Build();
var sm = g.ToAsyncStateMachine<AppAgent>();
sm.SetAgent(new AppAgent { Log = logger });

Blackboards (scoped shared memory)

Orthogonal to the agent: typed-key working memory with Global (one board shared across machines), Graph (one board per machine), and Node (transient per-visit scratch) scopes. Keys live on a BlackboardSchema; nodes access everything through the routed Bb context on the state bases — zero-boxing, zero-allocation reads/writes. Avoid Dictionary<string, object> contexts (string hashing + boxing per access).

Node-scoped boards are machine-owned: declare the schema on the graph via .WithSchema(...) and every machine auto-creates its own board — they can never be bound with WithBlackboard. Values reset to their registered defaults at every transition boundary (new visit, failure reroute, run start, reset, resume); in-place retries of the same visit keep the scratch. They are not durable: resuming a snapshot restores Node keys to defaults.

static class Keys
{
    public static readonly BlackboardSchema World = new("world", BlackboardScope.Global);
    public static readonly BlackboardKey<bool> Alarm = World.Register<bool>("Alarm");

    public static readonly BlackboardSchema Enemy = new("enemy"); // Graph scope
    public static readonly BlackboardKey<int> Distance = Enemy.Register<int>("Distance", 10);
}

sealed class ChaseState : AsyncState
{
    protected override ValueTask<Result> OnRunAsync(CancellationToken ct)
    {
        if (Bb.Get(Keys.Alarm)) Bb.GetRef(Keys.Distance)--; // routed by schema scope
        return ResultHelpers.Success;
    }
}

Graph graph = GraphBuilder
    .StartWithAsync(new ChaseState())
    .If(bb => bb.Get(Keys.Alarm)).ThenAsync((bb, ct) => ResultHelpers.Success)
                                 .ElseAsync((bb, ct) => ResultHelpers.Success)
    .WithSchema(Keys.Enemy).WithSchema(Keys.World)   // opt-in bind-time validation
    .Build();

Blackboard world = new(Keys.World);                  // shared by every machine
var sm = graph.ToAsyncStateMachine()
    .WithBlackboard(world)
    .WithBlackboard(new Blackboard(Keys.Enemy));     // this machine's own memory

Boards serialize independently via BlackboardSerializer (NxGraph.Serialization) — one payload per board, restored into a live board over the same schema.

Event entry points

One graph can respond to several externally-raised, typed events, each entering the flow at its own entry chain. GraphBuilder.StartWithEvents() seeds an EventEntryState dispatcher as the start node; .On(key, e => chain) binds a CLR event type to a chain through a Graph-scoped BlackboardKey<TEvent> that carries the payload, and .Otherwise(...) declares the optional plain-run entry. Raise through the machines' typed overloads — ExecuteAsync<TEvent>(evt) / Execute<TEvent>(evt) / StepAsync<TEvent>(evt) — one event, one run; the machine must be idle and restart policies apply verbatim.

var shop = new BlackboardSchema("shop");
var orderPlaced = shop.Register<OrderPlaced>("orderPlaced");

Graph graph = GraphBuilder.StartWithEvents()
    .On(orderPlaced, e => e.ToAsync(orderPlaced, (order, bb, ct) => HandleAsync(order)))
    .WithSchema(shop)
    .Build();

var sm = graph.ToAsyncStateMachine().WithBlackboard(new Blackboard(shop));
await sm.ExecuteAsync(new OrderPlaced("o-1", 42m));

Serialization note: the dispatch table rides the graph payload since version 7 as plain structure (key names, runtime-stable event type names, targets, Otherwise target). Keys never serialize — a deserialized graph raises by resolving the event's type name and the delivery key by name against the machine's bound Graph board (BlackboardSchema.TryResolve<T>), with targeted errors on a missing name or changed value type. The event payload itself is ordinary board state, so BlackboardSerializer persists it and a run suspended mid-handler resumes with the payload intact — no event replay.

Behaviors

A state can be authored as a sequence of small reusable behaviors instead of a lambda: .ToBehaviors(...) / .ToBehaviorsAsync(...) run their entries in order, fail-fast (the first non-Success stops the sequence and the node fails — the node keeps the whole fault model: .Retry re-runs the list, .OnError reroutes). Fields are BlackboardValue<T> bindings — literal or blackboard key, any scope. The standard set is Log (report channel, formatted only when an observer is wired) and SetValue<T>; agent-typed behaviors (IBehavior<TAgent>) receive the machine-bound agent as a call parameter via .ToBehaviors<TAgent>(...).

Graph graph = GraphBuilder.Start()
    .ToBehaviors(new Log(LogSeverity.Info, playerName), new SetValue<int>(score, 100))
    .WithSchema(stats)
    .Build();

Serialization note: behavior composites ride the graph payload since version 8 as self-describing field lists. The standard set round-trips with zero options via the default BehaviorRegistry (closed generics and agent types resolve from runtime-stable names); key bindings ride by name and rebind against the machine's bound boards at execution. Custom behaviors implement ISerializableBehavior and register a factory on GraphSerializerOptions.BehaviorRegistry; the agent never rides — re-attach it via SetAgent.


Execution

var sm = graph.ToAsyncStateMachine(observer: myObserver);
var status = await sm.ExecuteAsync(ct);
  • Threading: execution is reentrancy‑guarded; call ExecuteAsync once per instance.
  • Cancellation: all logic receives a CancellationToken.
  • Errors: exceptions propagate unless you wrap logic/observer.

Validation

Validate a graph before running it.

GraphValidationResult results = GraphBuilder
    .StartWith(_ => ResultHelpers.Success).To(_ => ResultHelpers.Success)
    .Build().Validate();
if (results.HasErrors)
{
    foreach (GraphDiagnostic res in results.Diagnostics)
    {
        Console.WriteLine(res);
    }
}

//or throw exceptions in case of invalid graphs
GraphBuilder
    .StartWith(_ => ResultHelpers.Success).To(_ => ResultHelpers.Success)
    .Build().ValidateAndThrowIfErrorsDebug();

Checks include:

  • Broken edges (out of range)
  • Self‑loops (optional severity)
  • Reachability from the start node
  • Terminal path exists (no infinite director cycles)

Observability

State machine observers

Subscribe to lifecycle, node, and transition events.

public sealed class ConsoleObserver : IAsyncStateMachineObserver
{
    public ValueTask OnStartedAsync(int id, CancellationToken ct) => Write("started");
    public ValueTask OnNodeEnteredAsync(int id, int idx, CancellationToken ct) => Write($"enter {idx}");
    public ValueTask OnTransitionAsync(int id, int from, int to, string? label, CancellationToken ct) => Write($"{from}->{to} {label}");
    public ValueTask OnNodeExitedAsync(int id, int idx, CancellationToken ct) => Write($"exit {idx}");
    public ValueTask OnStoppedAsync(int id, CancellationToken ct) => Write("stopped");
    static ValueTask Write(string s) { Console.WriteLine(s); return ValueTask.CompletedTask; }
}

var sm = graph.ToAsyncStateMachine(observer: new ConsoleObserver());
await sm.ExecuteAsync(CancellationToken.None);

Observer exceptions bubble by default; wrap if you want best‑effort.

Tracing (OpenTelemetry‑friendly)

A built‑in tracing observer maps machine/node lifecycles to Activity spans/events so you can export to Jaeger/Tempo/Zipkin.

using var observer = new TracingObserver(activitySource);
var sm = graph.ToAsyncStateMachine(observer);
await sm.ExecuteAsync(ct);

Replay

Record execution for offline visualization or debugging and play it back deterministically.

var recorder = new ReplayRecorder();
var sm = graph.ToAsyncStateMachine(observer: recorder);
await sm.ExecuteAsync(ct);

var replay = new StateMachineReplay(recorder.GetEvents().Span);
replay.ReplayAll(
    evt => Console.WriteLine($"{evt.Timestamp:O} {evt.Type} {evt.Message}")
);


Visualization

Mermaid export

Export a static diagram for docs/PRs.

string mermaid = GraphBuilder.StartWith(_ => ResultHelpers.Success).SetName("Start")
            .To(_ => ResultHelpers.Success).SetName("Process" )
            .To(_ => ResultHelpers.Success).SetName("Release")
            .Build()
            .ToMermaid();
Console.WriteLine("Mermaid:");
Console.WriteLine(mermaid);

Example output:

flowchart LR
    0([Start]) --> 1([Process])
    1 --> 2([Release])
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  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 is compatible.  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. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.1

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on NxGraph:

Package Downloads
NxGraph.Serialization.Abstraction

Serializer and codec abstractions for NxGraph — reference this package to implement custom graph serializers or node-logic codecs without depending on a concrete format.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.2.0-alpha 145 8/15/2026
2.1.0-alpha 141 7/17/2026
2.0.1-alpha 111 4/27/2026
2.0.0-alpha 112 4/16/2026
1.1.0 261 9/7/2025
1.0.3 244 9/4/2025
1.0.1 253 9/4/2025
1.0.0 364 9/3/2025