Aaron.Akka.Durable.TestKit 0.1.0-alpha.3

This is a prerelease version of Aaron.Akka.Durable.TestKit.
There is a newer prerelease version of this package available.
See the version list below for details.
dotnet add package Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.3
                    
NuGet\Install-Package Aaron.Akka.Durable.TestKit -Version 0.1.0-alpha.3
                    
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="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.3" />
                    
Directory.Packages.props
<PackageReference Include="Aaron.Akka.Durable.TestKit" />
                    
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 Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.3
                    
#r "nuget: Aaron.Akka.Durable.TestKit, 0.1.0-alpha.3"
                    
#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 Aaron.Akka.Durable.TestKit@0.1.0-alpha.3
                    
#: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=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.3&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.3&prerelease
                    
Install as a Cake Tool

Akka.Durable

Akka.Durable is a proof of concept, not an official Akka.NET project. It is published as Aaron.Akka.Durable because the Akka.* NuGet prefix is reserved for official Akka.NET packages. Expect breaking changes between releases.

Akka.Durable gives you event-sourced entities on Akka.NET. Define their behavior with pure Decide and Evolve functions, keep the impure work in effect handlers, then send commands through typed references. The runtime handles persistence, recovery, and routing in local or clustered deployments.

Inject it. Send a message.

Entity reference factories are .NET DI services. Call AddDurableEntities() at startup, then inject IEntityRefs<InvoiceCommand> into any controller, hosted service, or application service:

public sealed class BillingService(IEntityRefs<InvoiceCommand> invoices)
{
    public Task<IssueReply> IssueAsync(EntityId id, CancellationToken ct) =>
        invoices.Get(id).Ask(new IssueInvoice(250m, "billing@example.com"), ct);

    public void Remind(EntityId id) =>
        invoices.Get(id).Tell(new SendReminder());
}

That's the caller API: choose an ID, send a command, get its declared reply type. The first message creates or recovers the entity. The same calls work with local hosting and Cluster Sharding. Register and host the entity once at startup.

Define its behavior

Here is the Invoice behind those calls. Decide produces events and effects; Evolve computes the state from each persisted event. Issuing an invoice needs an answer from a recipient directory, so Decide records that verification started and issues a VerifyRecipient effect. The directory's answer comes back as a command, RecipientVerified or RecipientRejected, and a second arm finishes the job.

using InvoiceContext = DecisionContext<InvoiceState, InvoiceCommand, InvoiceEvent>;

public static class Invoice
{
    public static readonly EntityDefinition<InvoiceState, InvoiceCommand, InvoiceEvent> Definition =
        new(new EntityTypeName("invoices"), _ => new Draft(), Decide, Evolve);

    public static Decision<InvoiceCommand, InvoiceEvent> Decide(
        InvoiceState state, InvoiceCommand command, InvoiceContext ctx) =>
        (state, command) switch
        {
            // Needs an outside answer: remember that we asked, then ask.
            (Draft, IssueInvoice c) when c.Total > 0 =>
                ctx.Persist(new VerificationStarted(c)).Execute(new VerifyRecipient(c.Recipient)),

            // The answer arrives as a command. The request kept in state fixes the reply type.
            (Verifying v, RecipientVerified) =>
                ctx.Persist(new InvoiceIssued(v.Request.Total, v.Request.Recipient, ctx.UtcNow))
                    .Schedule("reminder", TimeSpan.FromDays(30), new SendReminder())
                    .Reply(v.Request, new InvoiceAccepted(ctx.EntityId)),
            (Verifying v, RecipientRejected r) =>
                ctx.Persist(new VerificationFailed(r.Reason)).Reply(v.Request, new InvoiceRejected(r.Reason)),
            (_, RecipientVerified or RecipientRejected) => ctx.Ignore(),

            (Issued s, RecordPayment p) when p.Amount == s.Total =>
                ctx.Persist(new PaymentRecorded(p.Amount))
                    .CancelTimer("reminder")
                    .Reply(p, new PaymentAccepted()),

            // Nothing comes back from a reminder, so best-effort Run is enough.
            (Issued, SendReminder) => ctx.None().Run<IInvoiceMailer>((mailer, ct) => mailer.RemindAsync(ct)),
            (_, SendReminder) => ctx.Ignore(),

            (_, IssueInvoice c) => ctx.None().Reply(c, new InvoiceRejected("Invalid invoice operation")),
            (_, RecordPayment p) => ctx.None().Reply(p, new InvoiceRejected("Invalid invoice operation")),
        };

    public static InvoiceState Evolve(InvoiceState state, InvoiceEvent @event) =>
        (state, @event) switch
        {
            (Draft, VerificationStarted e) => new Verifying(e.Request),
            (Verifying, VerificationFailed) => new Draft(),
            (Verifying, InvoiceIssued e) => new Issued(e.Total, e.Recipient),
            (Issued s, PaymentRecorded) => new Paid(s.Total),
            _ => throw new InvalidOperationException($"Cannot apply {@event} to {state}."),
        };
}

VerifyRecipient is a plain record, public sealed record VerifyRecipient(string Recipient);, and it is never serialized. The only impure code is its handler, which Akka.Durable resolves from a fresh DI scope each time the effect runs. The handler's second type argument says what kind of outcome it produces, here a command that goes back through Decide:

public sealed class VerifyRecipientHandler(IRecipientDirectory directory)
    : IEffectHandler<VerifyRecipient, InvoiceCommand>
{
    public async Task<InvoiceCommand> HandleAsync(VerifyRecipient effect, CancellationToken ct) =>
        await directory.LookupAsync(effect.Recipient, ct) switch
        {
            Known => new RecipientVerified(),
            Unknown u => new RecipientRejected(u.Reason),
        };
}

// at startup, next to the other services
services.AddScoped<IEffectHandler<VerifyRecipient, InvoiceCommand>, VerifyRecipientHandler>();

The state, command, event, and reply declarations are in the guide. You write the domain functions and the handlers; Akka.Durable supplies the actor.

The calling surface is small: IEntityRef sends commands, and IDurableEntityRef adds state reads:

public interface IEntityRef<in TCommand>
{
    EntityId Id { get; }

    void Tell(TCommand command, IActorRef? sender = null);

    Task<TReply> Ask<TReply>(IReplies<TCommand, TReply> command,
        CancellationToken cancellationToken = default) where TReply : notnull;
}

public interface IDurableEntityRef<in TCommand, TState, TEvent> : IEntityRef<TCommand>
{
    Task<StateSnapshot<TState>> GetStateAsync(CancellationToken cancellationToken = default);
}

Ask infers its reply type from the command. GetStateAsync returns the committed state and its journal sequence number. Both reference factories can be injected: use IEntityRefs<InvoiceCommand> for messaging, or IDurableEntityRefs<InvoiceCommand, InvoiceState, InvoiceEvent> for messaging plus state reads. Both are registered by AddDurableEntities(). The registry is keyed by command type, so each entity type must have its own command type.

Getting started

Install the .NET 11 RC SDK pinned in global.json (including prerelease SDKs), then run:

dotnet restore
dotnet build
dotnet test

The solution targets net11.0 and enables preview C# features (C# 15 unions are used throughout). Package versions are managed centrally; Akka.NET packages are pinned to 1.6.0-beta7 (Aaron.Akka.Reminders to 1.6.0-beta5).

Package Contents
Aaron.Akka.Durable Entity definitions, decisions and effects, entity refs, the entity runtime, and Akka.Hosting integration. Everything is in the Akka.Durable namespace.
Aaron.Akka.Durable.TestKit Test helpers built on Akka.Hosting.TestKit, in the Akka.Durable.TestKit namespace

Guide

The rest of the documentation is in docs/, one topic per page. Read it in this order:

Page What it covers
State, commands, events, and replies How to declare an entity's state, commands, events and replies, and what a decision can do.
Effects with outcomes Execute and effect handlers, for work whose result the entity decides on.
Run or Execute Which of the two to use for service work.
Resuming after a restart Resume, for effects that were pending when the process stopped.
Talking to other entities Send and Accepts, for one entity that drives another.
Serialization What is serialized, and how to change stored types safely.
Hosting Local and sharded hosting, passivation and shard count.
Sending commands Entity refs, Ask and Tell, and entity IDs inside messages.
Reading state State-aware refs, and what a state read sees.
Workflows Finite processes that start once and end with an outcome: states, work, the fact table, and workflow refs.
Workflow rules Rules for writing a workflow that stays correct across restarts.
Testing Decisions as plain functions, and the hosted TestKit.
Status and direction What works today and what is not done yet.
Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
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-alpha.7 0 11/13/2026
0.1.0-alpha.6 43 11/12/2026
0.1.0-alpha.5 42 11/11/2026
0.1.0-alpha.4 45 11/11/2026
0.1.0-alpha.3 45 11/10/2026
0.1.0-alpha.2 44 11/10/2026
0.1.0-alpha.1 61 11/7/2026

Third alpha release of Akka.Durable. It makes a workflow read typed, moves to Akka.NET 1.6.0-beta7, refuses an effect time limit that is not positive, and logs a missing effect handler as an error. Akka.Durable is still a proof of concept, not an official Akka.NET project.

* **A workflow is read the way an entity is read.** `GetStateAsync()` on a workflow ref returns `StateSnapshot<WorkflowStatus<TState, TOutcome>>`. The status is `WorkflowNotStarted`, `WorkflowRunning<TState>` with the state as you typed it, or `WorkflowFinished<TOutcome>` with the outcome. For a state type that is a union, a `switch` expression over `WorkflowRunning<TState>.State` needs no catch-all arm. `StateName` gives the name of the state for logs. `Status()` is removed.
* **Workflow refs have four type arguments.** They are now `IWorkflowRef<TInput, TState, TFact, TOutcome>` and `IWorkflowRefs<TInput, TState, TFact, TOutcome>`, in the same order as `WorkflowDefinition`. A workflow type is found by all four types, and `Get` throws `InvalidOperationException` when no hosted workflow type has them. The `WorkflowRef` helper in `DurableEntityTestKit` takes the same four.
* **`SequenceNr` on a workflow read.** It is the sequence number of the hosting entity's journal. Compare it with an earlier read to see whether anything changed. It is not a count of state changes: a fact that keeps the state also adds one.
* **An effect time limit of zero or less is refused.** `WithDurableEntity` and `WithDurableWorkflow` throw `ArgumentOutOfRangeException` when `EntityOptions.EffectTimeout` is zero or negative. `Timeout.InfiniteTimeSpan` is negative, so it is refused too. The message names the entity or workflow type and the value.
* **A missing effect handler is an error in the log.** When an entity or a workflow issues an effect with `Execute` and no `IEffectHandler` is registered for it, the library logs an error. The entry names the effect type, the handler interface to register, and the entity or workflow type and ID. It does not contain the effect's contents. A workflow still receives the give-up fact with the reason `Failed`, and `WorkGaveUp.Error` names the interface. An entity still fails and restarts, as before.
* **Akka.NET 1.6.0-beta7.** It contains the fix for the command-order issue listed for 0.1.0-alpha.2 (akkadotnet/akka.net#8942).

Not yet implemented: workflow retry policies, `Again` with a delay, child workflows, a scenario test kit for workflows, state and event subscriptions, entity contracts and proxies, source generation, and snapshot retention tests. `VerifyMessageSerialization` stays off by default in the TestKit (see akkadotnet/akka.net#8910).

Known issue: an entity that fails right after recovery is restarted with no delay, so a failure that repeats on every recovery restarts it in a loop.