StateKit 1.0.0
dotnet add package StateKit --version 1.0.0
NuGet\Install-Package StateKit -Version 1.0.0
<PackageReference Include="StateKit" Version="1.0.0" />
<PackageVersion Include="StateKit" Version="1.0.0" />
<PackageReference Include="StateKit" />
paket add StateKit --version 1.0.0
#r "nuget: StateKit, 1.0.0"
#:package StateKit@1.0.0
#addin nuget:?package=StateKit&version=1.0.0
#tool nuget:?package=StateKit&version=1.0.0
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 |
Core state machine logic | |
StateKit.AspNetCore |
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 definitionsIStateManager/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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
-
net8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.3)
- Microsoft.Extensions.Options (>= 9.0.3)
-
net9.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.3)
- Microsoft.Extensions.Options (>= 9.0.3)
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 |