Sagant.Runtime.Akka 0.1.0

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

Sagant

Durable, step-orchestrated workflow engine for .NET. Extend one base class, mark methods as steps or command handlers, and get retries with compensation, pause/resume, step/workflow timeouts, and crash-safe recovery — without hand-rolling a state machine or a persistence model.

Sagant is split into a runtime-agnostic core and a pluggable runtime:

Package What it is
Sagant Workflow authoring: Workflow<TState>, effects, settings, [WorkflowStep]/[WorkflowCommandHandler]. No dependency on any execution engine. See docs/workflow-model.md.
Sagant.Runtime.Akka Runs a workflow via a persistent Akka.NET ClusterSharding entity actor. Implements Sagant's IWorkflowClient/IWorkflowHandle contract. See docs/akka-runtime.md.
Sagant.SourceGenerators Roslyn generator behind [WorkflowStep]/[WorkflowCommandHandler] — zero-reflection dispatch tables, AOT-friendly. See docs/workflow-model.md.
Sagant.Testing WorkflowTestHarness<TWorkflow, TState> for testing a workflow's own logic with zero infrastructure — no ActorSystem, no persistence. See docs/testing.md.

A different runtime just needs to drive Workflow<TState> through the generated dispatch tables and implement IWorkflowClient/IWorkflowHandle.

Docs: this README is a quickstart — for deeper coverage of the workflow model, design guidelines, child workflows, the Akka runtime's internals, integration, and testing, see docs/.

Quickstart

Reference Sagant and Sagant.Runtime.Akka.

The minimum: one required override, a command handler to start the run, a step to do something.

public sealed record GreetingState(string Name = "", string? Greeting = null);

public sealed record Greet(string Name);

public partial class GreetingWorkflow : Workflow<GreetingState>
{
    public override GreetingState EmptyState() => new();

    [WorkflowCommandHandler]
    public CommandEffect<GreetingState> Start(Greet cmd, CommandContext<GreetingState> ctx) =>
        Effects.UpdateState(ctx.State with { Name = cmd.Name }).TransitionTo(Steps.SayHello);

    [WorkflowStep]
    public StepEffect<GreetingState> SayHello(StepContext<GreetingState> ctx) =>
        StepEffects.UpdateState(ctx.State with { Greeting = $"Hello, {ctx.State.Name}!" }).ThenComplete();
}

EmptyState() is the only member Workflow<TState> actually requires — Settings(), retries, timeouts, pause, queries, and child workflows are all opt-in on top of this. Steps.SayHello is generated for you, a typed reference to the [WorkflowStep] method above, so a transition never relies on a magic string.

Register it:

services.AddAkka("my-system", builder => builder
    .WithClustering()
    .WithWorkflow<GreetingWorkflow, GreetingState>(() => new GreetingWorkflow()))
    .AddWorkflowClient();

Drive it — the only thing application code ever touches:

public sealed class GreetingService(IWorkflowClient client)
{
    public async Task<string> GreetAsync(string id, string name)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        var result = await client.For<GreetingWorkflow>(id)
            .RunAndAwaitResult<GreetingState>(new Greet(name), cancellationToken: cts.Token);

        return result.State.Greeting!;
    }
}

RunAndAwaitResult sends the command, waits for the run to reach a terminal status, and returns its final state — the caller gets the actual greeting back. That's a complete, runnable workflow — define, register, drive. IWorkflowClient/ IWorkflowHandle<TWorkflow> are the full public surface a caller ever touches: no IActorRef, ActorRegistry, or ClusterSharding type leaks into application code.

RunAndAwaitResult fits here because GreetingWorkflow finishes in one quick step. It's the right choice for a workflow bounded in seconds or minutes — for anything that might pause for hours or days, Send to kick it off and GetStatus/a query to check on it later fits the shape of that wait far better than tying up a caller's own thread for however long the run takes. See docs/integration-guide.md.

A fuller example

Real workflows add retries with failover, dependencies resolved from DI, and reads that don't wait on a running step. Here's one that does, built from the same three handler kinds above:

public partial class OrderFulfillmentWorkflow : Workflow<OrderState>
{
    public override OrderState EmptyState() => OrderState.Empty();

    public override WorkflowSettings Settings() => WorkflowSettings.Create()
        .DefaultStepTimeout(TimeSpan.FromSeconds(5))
        .StepRecovery(Steps.ChargePaymentStep, RecoverStrategy.WithMaxRetries(2).FailoverTo(Steps.RefundPaymentStep))
        .Build();

    [WorkflowCommandHandler]
    public CommandEffect<OrderState> Start(PlaceOrder cmd, CommandContext<OrderState> ctx) =>
        Effects.UpdateState(ctx.State with { Amount = cmd.Amount })
            .TransitionTo(Steps.ChargePaymentStep)
            .ThenReply("accepted");

    [WorkflowStep]
    public async Task<StepEffect<OrderState>> ChargePaymentStep(StepContext<OrderState> ctx)
    {
        var paymentId = await _payment.Charge(ctx.State.CustomerId, ctx.State.Amount, ctx.CancellationToken);
        return StepEffects.UpdateState(ctx.State with { PaymentId = paymentId }).ThenComplete();
    }

    [WorkflowStep]
    public async Task<StepEffect<OrderState>> RefundPaymentStep(StepContext<OrderState> ctx)
    {
        await _payment.Refund(ctx.State.PaymentId!, ctx.CancellationToken);
        return StepEffects.ThenFail("payment was refunded");
    }

    [WorkflowQuery]
    public QueryEffect Progress(GetProgress query, QueryContext<OrderState> ctx) =>
        QueryEffects.Reply(ctx.State.Status);
}

Registration is the same shape, with the factory now resolving IPaymentService from DI — the (builder, IServiceProvider) overload of AddAkka exists for exactly this, where the minimal example above had no dependency to resolve and used the plain Action<AkkaConfigurationBuilder> overload instead:

services.AddSingleton<IPaymentService, RealPaymentService>();

services.AddAkka("my-system", (builder, sp) => builder
    .WithClustering()
    .WithWorkflow<OrderFulfillmentWorkflow, OrderState>(() =>
        new OrderFulfillmentWorkflow(sp.GetRequiredService<IPaymentService>())))
    .AddWorkflowClient();

Driving it looks the same too, just with a typed reply this time:

public sealed class OrderPlacementService(IWorkflowClient client)
{
    public async Task<string> PlaceAsync(string orderId, int amount)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        return await client.For<OrderFulfillmentWorkflow>(orderId)
            .Request<PlaceOrder, string>(new PlaceOrder(amount), cts.Token);
    }
}

See docs/workflow-model.md for how the generator works, and docs/workflow-model.md#effects-applied-by-the-driver for the full set of transitions a handler can produce (pause, delete, await-children, and so on — not just the transition-to-next-step and end shown above).

Three handler kinds

Attribute Signature Runs
[WorkflowCommandHandler] (TCommand, CommandContext<TState>) -> CommandEffect<TState> Synchronously, on the workflow's own thread. Decides; never does I/O.
[WorkflowStep] (TInput?, StepContext<TState>) -> StepEffect<TState> Off-thread, with retries, timeouts and cancellation. Where I/O lives.
[WorkflowQuery] (TQuery, QueryContext<TState>) -> QueryEffect Off-thread, read-only, concurrently with a running step.

Steps and queries may be declared synchronously or as Task<...>; the generator adapts either. Command handlers are synchronous by design — a command that needs external data validates in the caller, or accepts and transitions into a step that does the work.

State reaches a handler as a value on its context, never as shared instance state, so a step suspended at an await and a handler dispatched while it waits cannot observe each other.

QueryEffect carries a reply and nothing else — no persistence, no transition. That's a compile-time property, and it's what lets a query dispatch immediately, with no running step to queue behind: there's no write for it to race with. Reach for a query for anything a caller reads (a live progress view, a dashboard poll); reach for a command when the workflow should move.

The engine never checks the workflow's current state itself before routing a command to its handler by type: guarding which states a command is valid from is the handler's own job — inspect ctx.State and return a NoPersistence/no-transition effect (e.g. a rejection reply) when called somewhere it shouldn't be. Skip that guard and the handler will happily run — and transition/update state — even after the workflow has already reached a terminal state.

The reference Akka.NET runtime additionally guarantees a command handler never runs against a state a step still in flight is about to supersede — see docs/akka-runtime.md#command-dispatch-waits-for-an-in-flight-step-to-settle.

Retries, backoff, per-step timeout overrides, and pause-with-timeout are all configured through Settings() — see docs/workflow-model.md#settings-retries-and-pause.

Three verbs on a handle, between the two you've already seen: Send mutates without waiting (the GreetingService example), Request mutates and returns a reply (OrderPlacementService), and Query observes:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var status = await client.For<OrderFulfillmentWorkflow>(orderId)
    .Query<GetProgress, OrderStatus>(new GetProgress(), cts.Token);

A query takes a different route from a command — delivered directly, bypassing the at-least-once machinery entirely (guaranteed delivery of a read buys nothing), and dispatched with no wait for a running step. It also carries no idempotency key: replaying a read has no side effect to deduplicate. Its handler is bounded by the workflow's own DefaultQueryTimeout, because a caller's timeout ends the caller's wait and never reaches the workflow.

IWorkflowClient/IWorkflowHandle<TWorkflow> are the full public surface. No IActorRef, ActorRegistry, or ClusterSharding types in application code. See docs/integration-guide.md for single-node vs. multi-node clustering, joining the cluster, and deployment-level tuning (shard count, idle-entity passivation, etc.), and docs/akka-runtime.md for how WorkflowEntityActor actually drives all of this — persistence, timeouts, retries, Akka.Delivery, and graceful shutdown.

Child workflows

A step can start other workflow instances as children and durably wait for their outcomes:

[WorkflowStep]
public StepEffect<OrderState> StartLineItemWorkflows(StepContext<OrderState> ctx)
{
    var children = ctx.State.LineItems.Select(item =>
        StepEffects.Child<LineItemWorkflow>(item.Sku, new ProcessLineItem(item.Sku, item.Quantity)));

    return StepEffects.AwaitChildren(children, Steps.OnLineItemsDone);
}

[WorkflowStep]
public StepEffect<OrderState> OnLineItemsDone(ChildGroupResult result, StepContext<OrderState> ctx) =>
    result.Outcome == GroupOutcome.Succeeded
        ? StepEffects.UpdateState(ctx.State with { LineItems = result.GetAll<LineItemWorkflow, LineItemState>().Values.ToList() }).ThenComplete()
        : StepEffects.ThenFail("line item failure");

By default, every child must succeed (CompletionPolicy.AllSuccessful), the group fails fast on the first failure (FailurePolicy.FailFast), and remaining children are terminated (RemainingChildrenPolicy.Terminate) — all three are independently configurable via the Action<ChildGroupOptions> overload of AwaitChildren. See docs/child-workflows.md for the full model, including heterogeneous groups, ParentClosePolicy, and durability guarantees.

Testing a workflow

WorkflowTestHarness<TWorkflow, TState> (in Sagant.Testing) drives a workflow's own step/command logic directly — no ActorSystem, no persistence, no ClusterSharding:

var harness = new WorkflowTestHarness<OrderFulfillmentWorkflow, OrderState>(
    new OrderFulfillmentWorkflow(fakePaymentService));

var effect = await harness.RunUntilStop(new PlaceOrder(500));

Assert.IsType<Transition.TerminalTransition>(effect.Transition);

var status = await harness.RunQuery<GetProgress, OrderStatus>(new GetProgress());

A step that throws is retried against its configured RecoverStrategy, then fails over once the retry budget is exhausted — retry/backoff/failover policy is testable directly here, in milliseconds, with no ActorSystem.

The harness takes a TimeProvider, so a paused workflow's timeout is testable too: advance a FakeTimeProvider past PauseSettings.Timeout and call RunPauseTimeoutIfDue() to assert it auto-transitions into the configured handler step. See docs/testing.md for retries, workflow-level timeouts, and testing child workflows.

Observability

Sagant fully supports OpenTelemetry: every command and step execution is a span, correlated into one trace per workflow run. Point an OTLP exporter at it and you get full step-by-step tracing with no extra instrumentation code. samples/OrderFulfillment does exactly this — run the AppHost and watch every order's traces live in the Aspire dashboard. See docs/integration-guide.md#observability for the DI wiring, and docs/akka-runtime.md#tracing for how spans stay correlated across retries, crashes, and cluster relocation.

The worked example

samples/OrderFulfillment is a full saga: multi-step orchestration across four simulated services, retries with compensation cascades, pause-for-approval with a timeout handler, step and workflow-level timeouts — all exercised through the real IWorkflowClient/ClusterSharding path in OrderFulfillment.Tests, and rendered live in OrderFulfillment.Sample (a 3-node cluster with a Razor Pages + Server-Sent Events UI on every replica: place an order, watch steps execute/retry/compensate in real time, approve a paused order). See docs/integration-guide.md#the-worked-example for a walkthrough of its host wiring.

License

MIT

Product Compatible and additional computed target framework versions.
.NET 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 Sagant.Runtime.Akka:

Package Downloads
Sagant.Scheduling.Akka

Akka.NET registration for Sagant.Scheduling. Kept apart from Sagant.Scheduling so that package depends on the core alone and stays usable from any runtime — this one is where the Akka-specific wiring lives.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0 171 8/19/2026

First release. Sagant is a durable, step-orchestrated workflow engine for .NET: extend , mark methods //, and get retries with compensation, pause/resume, step/workflow timeouts, child workflows, and crash-safe recovery without hand-rolling a state machine.

**Packages**
-  — runtime-agnostic core: , effects, settings, attributes.
-  — the reference runtime, backed by a persistent Akka.NET  entity actor per workflow instance.
-  —  for exercising a workflow's own logic with no , no persistence.
-  /  — recurring cron/interval schedules that start other workflows on a cadence.

**Highlights**
- Retries with backoff, failover, and parking a run for a person to resume.
- Pause-for-approval and operator suspend/resume, both with optional timeouts.
- Child workflows: fan-out,  groups, completion/failure/remaining-children policies, .
- At-least-once command delivery via , with an opt-in caller-supplied idempotency key.
- Full  tracing (one trace per run, correlated across retries/crashes/relocation) and OpenTelemetry metrics.
-  — a full worked example: multi-step saga, compensation cascades, pause-for-approval, a 3-node Aspire cluster with a live UI.

See [](https://github.com/yanpitangui/sagant/tree/main/docs) for the full guarantees contract, workflow model, and integration guide.