The good thing about agentic coding is, you can easily awake dead repos out of experiments back to life. Event sourced grain state machines is such an example.
Short version: take a clean Stateless
state machine, host it as a grain, and let events be the source of truth. You get audit, replay, and fewer “what just happened?” moments.
Why this combo?
Stateless gives a tidy, strongly-typed FSM API. GitHub
Orleans gives virtual actors, timers/reminders, and consistent single-threaded execution per grain. Microsoft Learn
Orleans.StateMachineES wraps them and adds event sourcing, snapshots, and nice enterprise extras like streams & versioning. GitHub
Install
dotnet add package Orleans.StateMachineES
(See README for exact version and notes.) GitHub
1) Define states, triggers, and events
public enum OrderState { Created, Paid, Shipped, Cancelled }
public enum OrderTrigger { Pay, Ship, Cancel }
// Event payloads (source of truth)
public abstract record OrderEvent(DateTime OccurredAt);
public record OrderPaid(decimal Amount, DateTime OccurredAt) : OrderEvent(OccurredAt);
public record OrderShipped(string Carrier, DateTime OccurredAt) : OrderEvent(OccurredAt);
public record OrderCancelled(string Reason, DateTime OccurredAt) : OrderEvent(OccurredAt);
// Grain state (can be rebuilt from events)
public record OrderGrainState(OrderState State, decimal TotalPaid, string? LastCarrier);
2) Implement an event-sourced state machine grain
using Orleans.StateMachineES;
using Stateless;
using static OrderState;
using static OrderTrigger;
public interface IOrderGrain : IGrainWithStringKey
{
Task<OrderState> GetState();
Task Pay(decimal amount, string idempotencyKey);
Task Ship(string carrier, string idempotencyKey);
Task Cancel(string reason, string idempotencyKey);
}
[StorageProvider(ProviderName = "Default")]
public sealed class OrderGrain
: EventSourcedStateMachineGrain<OrderState, OrderTrigger, OrderGrainState>,
IOrderGrain
{
protected override void ConfigureEventSourcing(EventSourcingOptions opt)
{
// Event sourcing knobs (see README)
opt.AutoConfirmEvents = true; // apply+persist in one go
opt.SnapshotInterval = 100; // every N events
opt.EnableIdempotency = true; // dedupe by key
}
protected override StateMachine<OrderState, OrderTrigger> BuildStateMachine()
{
var sm = new StateMachine<OrderState, OrderTrigger>(Created);
sm.Configure(Created)
.Permit(Pay, Paid)
.Permit(Cancel, Cancelled);
sm.Configure(Paid)
.Permit(Ship, Shipped)
.Permit(Cancel, Cancelled);
sm.Configure(Shipped); // terminal for this sample
sm.Configure(Cancelled); // terminal
return sm;
}
// Map incoming triggers to domain events
public async Task Pay(decimal amount, string key)
=> await FireAsync(Pay, key, new OrderPaid(amount, DateTime.UtcNow));
public async Task Ship(string carrier, string key)
=> await FireAsync(Ship, key, new OrderShipped(carrier, DateTime.UtcNow));
public async Task Cancel(string reason, string key)
=> await FireAsync(Cancel, key, new OrderCancelled(reason, DateTime.UtcNow));
public Task<OrderState> GetState() => GetStateAsync();
// Apply events to rebuild state
protected override OrderGrainState Apply(OrderGrainState state, object @event) => @event switch
{
OrderPaid e => state with { State = Paid, TotalPaid = state.TotalPaid + e.Amount },
OrderShipped e => state with { State = Shipped, LastCarrier = e.Carrier },
OrderCancelled _ => state with { State = Cancelled },
_ => state
};
}
Notes
EventSourcedStateMachineGrain<…>
follows Orleans’ event-sourcing model (akin toJournaledGrain
) but wrapped for FSMs. You rebuild state by applying events; snapshots keep load times sane. Microsoft Learn+1FireAsync(trigger, idempotencyKey, event)
(API per README) drives transitions and persists the event once confirmed. Idempotency avoids double-fire under retries. GitHub
3) Minimal client call flow
var grain = client.GetGrain<IOrderGrain>("ORD-42");
await grain.Pay(99.00m, "pay#txn-123");
await grain.Ship("DHL", "ship#awb-987");
var s = await grain.GetState(); // Shipped
4) Timeouts with timers & reminders (SLA guards)
Use a reminder to auto-cancel unshipped orders after e.g. 24h—reminders survive deactivation and are durable. Microsoft Learn
public override async Task OnActivateAsync(CancellationToken ct)
{
await base.OnActivateAsync(ct);
await RegisterOrUpdateReminder("ship-deadline", TimeSpan.FromHours(24), TimeSpan.FromDays(365));
}
public async Task OnReminder(string reminderName)
{
if (reminderName == "ship-deadline" && (await GetState()) == OrderState.Paid)
await Cancel("Shipping deadline exceeded", "auto-cancel#ship-deadline");
}
(Replace with the reminder hook your host template uses; the idea stands.) Microsoft Learn
5) Streaming transitions (optional, but great for audits)
Publish transitions to Orleans Streams for a live audit trail and dashboards. The fork advertises stream integration; wire an IAsyncStream<OrderEvent>
and OnTransition
hook to push events. GitHub
6) Storage choice (TL;DR)
StateStorage provider: persists snapshots of state; leaner loads.
LogStorage provider: replays full event history each activation; great for rigorous audit/rebuild scenarios. Pick based on load profile and audit needs. Microsoft Learn
7) Quick benchmark sanity check
If you care about throughput, enable AutoConfirmEvents
. The README’s example shows a notable bump (it reduces round-trips/allocations on the hot path). Your mileage will vary—measure in your cluster. GitHub
8) A small checklist before prod
Idempotency keys for all external-facing triggers.
Snapshot interval appropriate for event volume.
Durable deadlines use reminders, not timers. Microsoft Learn
Log state transitions (streams or your sink of choice).
Tests around guards and parameterized triggers (Stateless makes this easy). GitHub
Appendix: bare-bones non-ES grain (for contrast)
public sealed class SimpleOrderGrain
: StateMachineGrain<OrderState, OrderTrigger>, IOrderGrain
{
protected override StateMachine<OrderState, OrderTrigger> BuildStateMachine()
=> new StateMachine<OrderState, OrderTrigger>(OrderState.Created)
.Configure(OrderState.Created).Permit(OrderTrigger.Pay, OrderState.Paid)
.Permit(OrderTrigger.Cancel, OrderState.Cancelled)
.Configure(OrderState.Paid).Permit(OrderTrigger.Ship, OrderState.Shipped);
}
Works. Just not auditable.
Top comments (0)