Hierarchical State Machines

Real-world behavior rarely fits into a flat list of states. Think of a network client: while Connected, it can be Authenticating, Authenticated, or Browsing; while Authenticated, it can be Browsing or Editing. Nalu.SharpState models these hierarchies through sub-state-machine regions, declared as nested partial classes with the [SubStateMachine] attribute.

The [SubStateMachine] attribute

A region is a partial class nested inside either the root [StateMachineDefinition] class or another [SubStateMachine] region. It refines a composite state declared in its immediately enclosing scope.

[SubStateMachine(parent: State.Connected)]
private partial class ConnectedRegion
{
    [StateDefinition(Initial = true)]
    private static IStateConfiguration Authenticating { get; } = ConfigureState()
        .OnAuthOk(t => t.TransitionTo(State.Authenticated));

    [StateDefinition]
    private static IStateConfiguration Authenticated { get; } = ConfigureState()
        .OnMessage(t => t
            .Stay()
            .Invoke<Microsoft.Extensions.Logging.ILoggerFactory>((ctx, args, loggerFactory) =>
                loggerFactory.CreateLogger("Network")
                    .LogInformation("Message: {Text}", args.Text)));
}

The attribute takes one mandatory constructor argument:

Argument Meaning Scoping rule
parent The composite state this region refines. Must be a [StateDefinition] declared in the immediately enclosing class (root or outer region).

Every [StateDefinition] inside the class is treated as a child of parent. Exactly one state in the region must be marked with [StateDefinition(Initial = true)]. Entering parent automatically resolves to that initial child; if it is itself a composite, resolution continues until a real leaf is reached.

Triggers must live on the root machine. Placing [StateTriggerDefinition] inside a [SubStateMachine] class is a build error (NSS009). Regions describe structure, not new inputs.

A two-level example

using Microsoft.Extensions.Logging;
using Nalu.SharpState;

public sealed class NetworkContext
{
    /// <summary>Example machine state: last inbound message length.</summary>
    public int LastMessageLength { get; set; }
}

[StateMachineDefinition(typeof(NetworkContext))]
public static partial class NetworkMachine
{
    [StateTriggerDefinition] static partial void Connect();
    [StateTriggerDefinition] static partial void Disconnect();
    [StateTriggerDefinition] static partial void AuthOk();
    [StateTriggerDefinition] static partial void Message(string text);
    [StateTriggerDefinition] static partial void StartEdit();
    [StateTriggerDefinition] static partial void Save();

    [StateDefinition(Initial = true)]
    private static IStateConfiguration Idle { get; } = ConfigureState()
        .OnConnect(t => t.TransitionTo(State.Connected));

    [StateDefinition]
    private static IStateConfiguration Connected { get; } = ConfigureState()
        .OnDisconnect(t => t.TransitionTo(State.Idle));

    [SubStateMachine(parent: State.Connected)]
    private partial class ConnectedRegion
    {
        [StateDefinition(Initial = true)]
        private static IStateConfiguration Authenticating { get; } = ConfigureState()
            .OnAuthOk(t => t.TransitionTo(State.Authenticated));

        [StateDefinition]
        private static IStateConfiguration Authenticated { get; } = ConfigureState()
            .OnMessage(t => t.Stay().Invoke<ILoggerFactory>((ctx, args, loggerFactory) =>
            {
                ctx.LastMessageLength = args.Text.Length;
                loggerFactory.CreateLogger("Network")
                    .LogInformation("Message length {Length}", args.Text.Length);
            }));

        [SubStateMachine(parent: State.Authenticated)]
        private partial class AuthenticatedRegion
        {
            [StateDefinition(Initial = true)]
            private static IStateConfiguration Browsing { get; } = ConfigureState()
                .OnStartEdit(t => t.TransitionTo(State.Editing));

            [StateDefinition]
            private static IStateConfiguration Editing { get; } = ConfigureState()
                .OnSave(t => t.TransitionTo(State.Browsing));
        }
    }
}

The snippets above request ILoggerFactory as a callback service parameter. Use StateMachineServiceProviderResolver from Nalu.SharpState (add the Nalu.SharpState.DependencyInjection package) when creating the actor, as in the Service provider and actor factories section. A typical host registers ILoggerFactory by default.

The hierarchy this produces:

Idle
Connected
├── Authenticating
└── Authenticated
    ├── Browsing
    └── Editing

Entry, inheritance, and leaf resolution

Three rules govern how the runtime walks this tree.

1. Targeting a composite resolves to its initial leaf

using Microsoft.Extensions.DependencyInjection;
using Nalu.SharpState;
using Nalu.SharpState;

IServiceProvider services = ...; // composition root
var resolver = new StateMachineServiceProviderResolver(services, services.GetRequiredService<IServiceScopeFactory>());

var machine = NetworkMachine.CreateActor(new NetworkContext(), resolver);

machine.Connect();
// TransitionTo(Connected) -> initial Authenticating -> Authenticating has no deeper initial
machine.CurrentState.Should().Be(NetworkMachine.State.Authenticating);

machine.AuthOk();
// TransitionTo(Authenticated) -> initial Browsing -> Browsing is a leaf
machine.CurrentState.Should().Be(NetworkMachine.State.Browsing);
machine.IsIn(NetworkMachine.State.Authenticated).Should().BeTrue();
machine.IsIn(NetworkMachine.State.Connected).Should().BeTrue();

CurrentState always reports the leaf the machine settled on. Use IsIn(...) to test membership of a composite.

2. Transitions are inherited from ancestors

When a trigger fires, the engine walks up from the current leaf looking for a matching transition. The first ancestor that declares one (and whose guard, if any, passes) wins.

using Microsoft.Extensions.DependencyInjection;
using Nalu.SharpState;
using Nalu.SharpState;

IServiceProvider services = ...;
var resolver = new StateMachineServiceProviderResolver(services, services.GetRequiredService<IServiceScopeFactory>());

// Current state: Editing (deep leaf, 3 levels down)
var machine = NetworkMachine.CreateActorWithState(new NetworkContext(), resolver, NetworkMachine.State.Editing);

machine.Disconnect();
// Editing -> Authenticated -> Connected: Connected handles Disconnect -> target Idle
machine.CurrentState.Should().Be(NetworkMachine.State.Idle);
machine.IsIn(NetworkMachine.State.Connected).Should().BeFalse();

Any state may override an inherited transition by declaring its own .On<Trigger>(...) — the closer handler wins.

3. IsIn respects the hierarchy

machine.IsIn(NetworkMachine.State.Editing);      // exact leaf match
machine.IsIn(NetworkMachine.State.Authenticated); // ancestor
machine.IsIn(NetworkMachine.State.Connected);     // ancestor
machine.IsIn(NetworkMachine.State.Idle);          // sibling: false

This is how you write predicates like "can we Save right now?" without caring which leaf of Authenticated we are in.

Accessing states across regions

Every region sees the single State enum generated for the root machine. Inside AuthenticatedRegion you can still write State.Connected or State.Idle — the generator emits the enum on the outer machine class and every nested partial shares it.

[SubStateMachine(parent: State.Authenticated)]
private partial class AuthenticatedRegion
{
    [StateDefinition(Initial = true)]
    private static IStateConfiguration Editing { get; } = ConfigureState()
        .OnSave(t => t.TransitionTo(State.Browsing))
        // ancestor handles Disconnect, so no need to re-declare it here
        ;
}

Scoping rules enforced by the generator

Sub-state machines only make sense when they describe a strict containment tree. The generator enforces this at compile time:

  • parent must be a state declared in the immediately enclosing region. Pointing to a distant or sibling state produces NSS007.
  • Every region must declare exactly one [StateDefinition(Initial = true)]. Missing one produces NSS008; declaring more than one produces NSS010.
  • The region class must be partial and nested in a valid container (root [StateMachineDefinition] or another [SubStateMachine]). Free-standing regions produce NSS005.
  • [StateTriggerDefinition] is not allowed inside a region — add it on the root class instead. Violations produce NSS009.

See Diagnostics & Troubleshooting for the full list.

Graphviz, Mermaid, and nested regions

ToDot() renders clusters for each [SubStateMachine] region. ToMermaid() renders the same hierarchy with Mermaid composite state ... { ... } blocks. Both are generated on every machine.

For dynamic targets, pass labeled hints after the selector so the diagrams show real branches instead of a generic Dynamic target placeholder:

.OnRoute(t => t.TransitionTo(
    (ctx, args) => args.Request.IsAdmin ? State.AdminDashboard : State.UserDashboard,
    (State.AdminDashboard, "Admin request"),
    (State.UserDashboard, "Standard request")))

Hints are documentation only; runtime still uses the selector result. See the diagram export section on the home page for DOT and Mermaid examples.

A Stay on a composite is rendered differently per format:

  • Graphviz uses a hidden anchor node because subgraph borders cannot be edge endpoints.
  • Mermaid emits a self-loop on the composite state after the composite block closes, which keeps nested diagrams valid.

When to use hierarchy

Regions pay off when:

  • You have shared transitions that apply to a whole sub-tree (like Disconnect on Connected).
  • A composite has a clear default entry point that should be resolved automatically.
  • Two or more states form a cluster that would otherwise duplicate the same On<Trigger> handlers.

If your state graph is flat and every transition is local, stay flat — adding regions adds cognitive overhead without benefit.