StateKit.AspNetCore 1.0.0

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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 144 3/26/2026