Sagant.Testing 0.1.0

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

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
0.1.0 100 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.