Aaron.Akka.Durable.TestKit
0.1.0-alpha.3
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
<PackageReference Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.3" />
<PackageVersion Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.3" />
<PackageReference Include="Aaron.Akka.Durable.TestKit" />
paket add Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.3
#r "nuget: Aaron.Akka.Durable.TestKit, 0.1.0-alpha.3"
#:package Aaron.Akka.Durable.TestKit@0.1.0-alpha.3
#addin nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.3&prerelease
#tool nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.3&prerelease
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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- Aaron.Akka.Durable (>= 0.1.0-alpha.3)
- Akka.Hosting.TestKit (>= 1.6.0-beta7)
- xunit.v3.assert (>= 4.0.2)
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.