NxGraph 2.2.0-alpha
dotnet add package NxGraph --version 2.2.0-alpha
NuGet\Install-Package NxGraph -Version 2.2.0-alpha
<PackageReference Include="NxGraph" Version="2.2.0-alpha" />
<PackageVersion Include="NxGraph" Version="2.2.0-alpha" />
<PackageReference Include="NxGraph" />
paket add NxGraph --version 2.2.0-alpha
#r "nuget: NxGraph, 2.2.0-alpha"
#:package NxGraph@2.2.0-alpha
#addin nuget:?package=NxGraph&version=2.2.0-alpha&prerelease
#tool nuget:?package=NxGraph&version=2.2.0-alpha&prerelease
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
GraphofNode[]andTransition[], 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 aResult(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
Graphthat 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. UnderTimeoutBehavior.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
ExecuteAsynconce 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 | Versions 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. |
-
.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 |