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.
Status: production‑ready core; APIs around serialization/visualization may evolve.
- Why NxGraph
- Features
- Benchmarks
- Install
- Quick start
- Core concepts
- Authoring DSL
- Execution
- Validation
- Observability
- Visualization
- Serialization
- Performance notes
- Testing
- FAQ
- Roadmap
- Contributing
- License
- Simple, fast core: array‑backed
Graph
ofNode[]
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(...)
.
- ✅ 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

Scenario | NxFSM | Stateless |
---|---|---|
Chain10 | 0.4293 | 47.06 |
Chain50 | 1.6384 | 142.75 |
DirectorLinear10 | 0.4372 | 42.76 |
SingleNode | 0.1182 | 14.53 |
WithObserver | 0.1206 | 42.96 |
WithTimeoutWrapper | 0.2952 | 14.23 |
Scenario | NxFSM | Stateless |
---|---|---|
Chain10 | 0 | 15.07 |
Chain50 | 0 | 73.51 |
DirectorLinear10 | 0 | 15.07 |
SingleNode | 0 | 1.85 |
WithObserver | 0 | 15.42 |
WithTimeoutWrapper | 0 | 1.85 |
dotnet add package NxGraph
Additionally, you can clone the repository and reference projects directly, or build a local package.
# build
dotnet build -c Release
# (optional) create local package
dotnet pack -c Release
Add a project reference to NxGraph
(and NxGraph.Exporters
/ NxGraph.Serialization
if needed).
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.ToStateMachine();
await sm.ExecuteAsync(CancellationToken.None);
What you get
- Deterministic, single‑edge execution
- Easy branching via directors (see below)
- Hooks for tracing/visualization
- 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
Graph
that executes from a start node until terminal.
static ValueTask<Result> Start(CancellationToken _) => ResultHelpers.Success;
static ValueTask<Result> End(CancellationToken _) => ResultHelpers.Success;
var graph = GraphBuilder
.StartWith(Start)
.To(End)
.Build();
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();
var graph = GraphBuilder
.StartWith(Start)
.WaitFor(250.Milliseconds())
.To(End)
.Build();
// Timeout wrapper for a long-running state
var timeoutGraph = GraphBuilder
.StartWith(Start).ToWithTimeout(500.Milliseconds(), _=> ResultHelpers.Failure)
.To(Release)
.Build();
Timeout
cancels the wrapped logic if it exceeds the specified duration and routes to the next node. Prefer passing a linkedCancellationToken
inside your logic for graceful stops.
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.ToStateMachine<AppAgent>();
sm.SetAgent(new AppAgent { Log = logger });
var sm = graph.ToStateMachine(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.
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)
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.ToStateMachine(observer: new ConsoleObserver());
await sm.ExecuteAsync(CancellationToken.None);
Observer exceptions bubble by default; wrap if you want best‑effort.
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.ToStateMachine(observer);
await sm.ExecuteAsync(ct);
Record execution for offline visualization or debugging and play it back deterministically.
var recorder = new ReplayRecorder();
var sm = graph.ToStateMachine(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}")
);
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])
Serialize graphs (and replays) to JSON or MessagePack.
//Serialize
GraphSerializer.SetLogicCodec(new ExampleLogicSerializer());
ExampleState start = new() { Data = "start" };
ExampleState end = new() { Data = "end" };
Graph graph = GraphBuilder.StartWith(start).SetName("Start").To(end).SetName("End").Build().SetName("FSM");
MemoryStream stream = new();
await GraphSerializer.ToJsonAsync(graph, stream);
//Deserialize
Graph deserializedGraph = await GraphSerializer.FromJsonAsync(stream);
StateMachine fsm = deserializedGraph.ToStateMachine();
await fsm.ExecuteAsync();
Note: Current serializer uses a configurable logic codec. Future versions may expose an instance‑based
GraphSerializer
to avoid global state and improve concurrency.
- Keep logic static (avoid captures) for zero‑alloc authoring:
static ValueTask<Result> Ok(CancellationToken _) => ResultHelpers.Success;
- Unit tests cover reentrancy, cancellation, observers, validation, exporters, replay.
- Property tests (FsCheck) exercise director branching, exporter escaping, and serialization round-trips.
Run all tests:
dotnet test -c Release
Run only property tests:
dotnet test -c Release --filter Category=property
Q: Why a single outgoing edge per node?
A: It makes the graph compact and the runtime simple. Branching happens in directors, not by fanning out edges.
Q: Can I run multiple machines on the same graph?
A: Yes. Graphs are immutable and thread-safe for sharing.
Q: Do observer exceptions crash execution?
A: They bubble by default. Wrap if you need best‑effort telemetry.
Q: How do I draw my graph in Docs?
A: Use the Mermaid exporter to produce .mmd
files that GitHub/GitLab render natively.
- Instance‑based serializers (no global codec)
- Additional validators (cycle analysis across directors)
- Realtime Visualizer
- NuGet packaging & SourceLink
PRs welcome. Please run dotnet format
and ensure tests pass. For non‑trivial changes, open an issue first.
MIT (see LICENSE
).