StateKit 1.0.0

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

StateKit

Typed state machines for .NET entities — declared transitions, guards, hooks, and history tracking. Inspired by spatie/laravel-model-states.

Table of Contents


Packages

Package NuGet Description
StateKit NuGet Core state machine logic
StateKit.AspNetCore NuGet ASP.NET Core DI builder extensions

Installation

dotnet add package StateKit

For ASP.NET Core hosts:

dotnet add package StateKit.AspNetCore

Quick Start

1. Define your states

public abstract class TicketStatus : State<TicketStatus>
{
    public static TicketStatus Open       => new OpenState();
    public static TicketStatus InProgress => new InProgressState();
    public static TicketStatus Waiting    => new WaitingState();
    public static TicketStatus Resolved   => new ResolvedState();
    public static TicketStatus Closed     => new ClosedState();
}

public class OpenState       : TicketStatus { public override string Value => "open"; }
public class InProgressState : TicketStatus { public override string Value => "in_progress"; }
public class WaitingState    : TicketStatus { public override string Value => "waiting"; }
public class ResolvedState   : TicketStatus { public override string Value => "resolved"; }
public class ClosedState     : TicketStatus { public override string Value => "closed"; }

2. Declare allowed transitions

public class TicketStatusConfig : StateConfig<TicketStatus>
{
    public override void Configure(StateConfigBuilder<TicketStatus> builder)
    {
        builder.Default(TicketStatus.Open);

        builder.Allow(TicketStatus.Open,       TicketStatus.InProgress);
        builder.Allow(TicketStatus.Open,       TicketStatus.Closed);
        builder.Allow(TicketStatus.InProgress, TicketStatus.Waiting);
        builder.Allow(TicketStatus.InProgress, TicketStatus.Resolved);
        builder.Allow(TicketStatus.Waiting,    TicketStatus.InProgress);
        builder.Allow(TicketStatus.Waiting,    TicketStatus.Closed);
        builder.Allow(TicketStatus.Resolved,   TicketStatus.Closed);
        builder.Allow(TicketStatus.Resolved,   TicketStatus.InProgress);
        builder.Allow(TicketStatus.Closed,     TicketStatus.InProgress)
            .Guard<ReopenGuard>();
    }
}

3. Mark your entity

public class Ticket : IHasState<TicketStatus>
{
    public int Id { get; set; }
    public TicketStatus Status { get; set; } = TicketStatus.Open;
}

4. Register and use

// Program.cs / Startup
services.AddStateKit(options =>
{
    options.Register<TicketStatus, TicketStatusConfig>();
});

// In your service / handler
public class TicketService(IStateManager stateManager)
{
    public async Task<bool> StartAsync(Ticket ticket, CancellationToken ct)
    {
        var result = await stateManager.TransitionAsync(
            ticket, t => t.Status, TicketStatus.InProgress, ct);

        if (result.IsFailure)
            Console.WriteLine(result.Error);

        return result.IsSuccess;
    }
}

Concepts

States

Every state is a class that inherits from State<T> where T is the abstract base of the hierarchy.

public abstract class OrderStatus : State<OrderStatus> { }

public class PendingState   : OrderStatus { public override string Value => "pending"; }
public class ShippedState   : OrderStatus { public override string Value => "shipped"; }
public class DeliveredState : OrderStatus { public override string Value => "delivered"; }

States are compared by Value, not reference. Two instances of PendingState are equal:

var a = new PendingState();
var b = new PendingState();
a == b; // true

State Configuration

Create a class extending StateConfig<T> and override Configure:

public class OrderStatusConfig : StateConfig<OrderStatus>
{
    public override void Configure(StateConfigBuilder<OrderStatus> builder)
    {
        builder.Default(OrderStatus.Pending);

        builder.Allow(OrderStatus.Pending, OrderStatus.Shipped);
        builder.Allow(OrderStatus.Shipped, OrderStatus.Delivered);
    }
}

builder.Default(...) documents the initial state. It is informational — StateKit does not automatically set defaults on entities; initialise the property in the class declaration instead.


Entities

Implement IHasState<T> as a compile-time marker. There are no required members — just the property you want managed:

public class Order : IHasState<OrderStatus>
{
    public OrderStatus Status { get; set; } = OrderStatus.Pending;
}

The property must have a setter for TransitionAsync to mutate it.


Guards

Guards provide runtime conditional logic. Implement ITransitionGuard<T> and attach it to a transition with .Guard<TGuard>():

public class ReopenGuard : ITransitionGuard<TicketStatus>
{
    private readonly IBillingService _billing;

    public ReopenGuard(IBillingService billing) => _billing = billing;

    public async Task<TransitionGuardResult> CheckAsync(
        TicketStatus from, TicketStatus to, object entity, CancellationToken ct)
    {
        if (entity is not Ticket ticket)
            return TransitionGuardResult.Deny("Unexpected entity type.");

        var isLocked = await _billing.IsLockedAsync(ticket.Id, ct);
        return isLocked
            ? TransitionGuardResult.Deny("Ticket is locked by an open invoice.")
            : TransitionGuardResult.Allow();
    }
}
builder.Allow(TicketStatus.Closed, TicketStatus.InProgress)
    .Guard<ReopenGuard>();

Guards are resolved from the DI container. Register them as scoped or transient services:

services.AddScoped<ReopenGuard>();

When a guard denies the transition, TransitionAsync returns a failure result with the guard's reason embedded in Error. The state property is not mutated.

Multiple guards can be chained — all must pass:

builder.Allow(TicketStatus.Closed, TicketStatus.InProgress)
    .Guard<ReopenGuard>()
    .Guard<AuditApprovalGuard>();

Hooks

Hooks run before and after a successful transition for side effects such as sending notifications or publishing events. Implement ITransitionHook<T> and attach with .Hook<THook>():

public class TicketResolvedHook : ITransitionHook<TicketStatus>
{
    private readonly IEmailService _email;

    public TicketResolvedHook(IEmailService email) => _email = email;

    public Task BeforeTransitionAsync(TicketStatus from, TicketStatus to, object entity, CancellationToken ct)
        => Task.CompletedTask; // nothing to do before

    public async Task AfterTransitionAsync(TicketStatus from, TicketStatus to, object entity, CancellationToken ct)
    {
        if (to == TicketStatus.Resolved && entity is Ticket ticket)
            await _email.SendResolutionNoticeAsync(ticket.Id, ct);
    }
}
builder.Allow(TicketStatus.InProgress, TicketStatus.Resolved)
    .Hook<TicketResolvedHook>();

If BeforeTransitionAsync throws, the transition is aborted and the state property is not mutated. Exceptions from AfterTransitionAsync propagate to the caller; the state has already been set at that point.


Transition History

Enable in-memory recording of all transitions by implementing IHasTransitionHistory<T> on your entity:

public class Ticket : IHasState<TicketStatus>, IHasTransitionHistory<TicketStatus>
{
    public TicketStatus Status { get; set; } = TicketStatus.Open;
    public IList<TransitionRecord<TicketStatus>> TransitionHistory { get; } = new List<TransitionRecord<TicketStatus>>();
}

Each TransitionRecord<T> exposes:

Property Type Description
From T State before the transition
To T State after the transition
OccurredAt DateTimeOffset UTC timestamp

Only successful transitions are recorded. Failed transitions and same-state no-ops produce no record.


Multiple State Fields

A single entity can participate in multiple independent state machines:

public class Order : IHasState<FulfillmentStatus>, IHasState<PaymentStatus>
{
    public FulfillmentStatus FulfillmentStatus { get; set; } = FulfillmentStatus.Pending;
    public PaymentStatus     PaymentStatus     { get; set; } = PaymentStatus.Unpaid;
}

Register both configs and transition them independently:

services.AddStateKit(options =>
{
    options.Register<FulfillmentStatus, FulfillmentStatusConfig>();
    options.Register<PaymentStatus, PaymentStatusConfig>();
});

// Transition fulfillment
await stateManager.TransitionAsync(order, o => o.FulfillmentStatus, FulfillmentStatus.Shipped);

// Transition payment (independent)
await stateManager.TransitionAsync(order, o => o.PaymentStatus, PaymentStatus.Paid);

Querying Allowed Transitions

Use CanTransition and GetAllowedTransitions to inspect the declared graph without performing a transition (guards are not evaluated):

bool canResolve = stateManager.CanTransition(TicketStatus.Open, TicketStatus.Resolved);
// false — not declared

IReadOnlyList<TicketStatus> next = stateManager.GetAllowedTransitions(TicketStatus.Open);
// [ InProgress, Closed ]

These are useful for building UI elements that only show valid actions.


Transition Result

TransitionAsync returns a TransitionResult — never throws for business-rule failures:

var result = await stateManager.TransitionAsync(ticket, t => t.Status, TicketStatus.Resolved);

if (result.IsSuccess)
{
    // state has been mutated
}

if (result.IsFailure)
{
    Console.WriteLine(result.Error);
    // e.g. "Transition from 'open' to 'resolved' is not allowed."
    // or  "Guard 'ReopenGuard' denied the transition from 'closed' to 'in_progress': Ticket is locked."
}

DI Registration

services.AddStateKit(options =>
{
    options.Register<TicketStatus, TicketStatusConfig>();
    options.Register<OrderStatus, OrderStatusConfig>();
});

// Register guards and hooks with any lifetime (scoped recommended)
services.AddScoped<ReopenGuard>();
services.AddScoped<TicketResolvedHook>();

AddStateKit registers:

  • StateKitOptions — singleton, holds compiled transition definitions
  • IStateManager / StateManager — scoped

ASP.NET Core Integration

The StateKit.AspNetCore package adds convenience extension methods on IStateKitBuilder:

services
    .AddStateKit(options =>
    {
        options.Register<TicketStatus, TicketStatusConfig>();
    })
    .AddGuard<ReopenGuard>()
    .AddHook<TicketResolvedHook>();

AddGuard<T> and AddHook<T> register the type as a scoped service on the underlying IServiceCollection. They are strictly convenience wrappers — you can always call builder.Services.AddScoped<T>() directly.


Targets

Package Target frameworks
StateKit net8.0, net9.0, net10.0
StateKit.AspNetCore net8.0, net10.0

License

MIT — see LICENSE.

Product Compatible and additional computed target framework versions.
.NET 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 is compatible.  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 is compatible.  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 (1)

Showing the top 1 NuGet packages that depend on StateKit:

Package Downloads
StateKit.AspNetCore

ASP.NET Core integration for StateKit — convenience DI extension methods and builder extensions for ASP.NET Core hosts.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 242 3/26/2026