Aaron.Akka.Durable.TestKit
0.1.0-alpha.4
See the version list below for details.
dotnet add package Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.4
NuGet\Install-Package Aaron.Akka.Durable.TestKit -Version 0.1.0-alpha.4
<PackageReference Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.4" />
<PackageVersion Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.4" />
<PackageReference Include="Aaron.Akka.Durable.TestKit" />
paket add Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.4
#r "nuget: Aaron.Akka.Durable.TestKit, 0.1.0-alpha.4"
#:package Aaron.Akka.Durable.TestKit@0.1.0-alpha.4
#addin nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.4&prerelease
#tool nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.4&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()),
// A lost reminder mail is acceptable, so the light, unrecorded 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 the effect: plain data, journaled in the same write as VerificationStarted. It says which command Decide gets when the directory gives no answer:
[AkkaSerializable(Manifest = "verify-recipient-v1")]
public sealed record VerifyRecipient([property: AkkaField(0)] string Recipient) : IInvoiceProtocol, IEffect<InvoiceCommand>
{
public InvoiceCommand GaveUp(WorkGaveUp g) => new RecipientRejected($"directory: {g.Reason}");
}
The only impure code is its handler, which Akka.Durable resolves from a fresh DI scope when the effect runs. The handler's second type argument says what kind of answer 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, EffectDelivery delivery, 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.AddEffectHandler<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.
You do not need to build an outbox. Akka.Durable has one.
ctx.Persist(new PaymentRecorded(p.Amount)).Execute(new PublishPayment(ctx.EntityId, p.Amount)); // in Decide
services.AddEffectHandler<PublishPayment, PublishPaymentHandler>(Retry.UntilAcknowledged(TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(5)));
public sealed class PublishPaymentHandler(IBus bus) : IEffectHandler<PublishPayment> { /* ... */ }
PublishPayment is recorded in the same journal write as PaymentRecorded, and its handler runs at least once, after a crash too, until it returns without throwing; it can run more than once, so it must be safe to repeat.
See Effects for the other retry policies, and for Execute without a policy, which is attempted once and tells Decide how it ended.
Getting started
Install the .NET 11 SDK pinned in global.json, 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 | Execute, the normal way to do outside work, retry policies (the outbox), and the light forms Run, Tell and Send. |
| Effects with outcomes | Effect handlers, answers and give-ups, for work whose result the entity decides on. |
| Talking to other entities | Send and Accepts, for one entity that drives another. |
| Starting a workflow from an entity | Uses, and Start, Notify and Cancel from a decision. |
| 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.4)
- 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.8 | 0 | 11/13/2026 |
| 0.1.0-alpha.7 | 0 | 11/13/2026 |
| 0.1.0-alpha.6 | 44 | 11/12/2026 |
| 0.1.0-alpha.5 | 43 | 11/11/2026 |
| 0.1.0-alpha.4 | 47 | 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 |
Fourth alpha release of Akka.Durable. An entity's `Execute` is now recorded with its decision, and a retry policy makes the library redeliver it, which gives entities a built-in outbox. An entity can also start, notify and cancel a workflow from a decision. This release changes parts of the 0.1.0-alpha.3 API. Akka.Durable is still a proof of concept, not an official Akka.NET project.
* **Every `Execute` is recorded.** The effect is journaled in the same atomic write as the events of the decision that issued it. It is attempted once, and `Decide` always gets its answer or a give-up: `Failed`, `TimedOut`, or `Interrupted` after a crash. A handler that throws is no longer logged and dropped.
* **A built-in outbox.** Register a handler with a retry policy, for example `AddEffectHandler<TEffect, THandler>(Retry.UntilAcknowledged(first, max))`, and the library redelivers the effect until the handler acknowledges it, after a crash, a passivation or a shard move too. `Retry.Every` and `Retry.Backoff` stop after a number of attempts and give up with `RetriesExhausted`. Delivery is at least once, so a handler that is registered with a policy must be safe to repeat.
* **`AddEffectHandler` registers a handler and its route.** A recovered effect finds its handler through the route. An entity effect whose handler has no route is refused at its first `Execute`. Two handlers for one effect type fail at startup.
* **`IEffect<TCommand>`.** An entity effect type implements it to turn a give-up into one of the entity's commands, and `Execute` requires it. Entity effect types are journaled, so they need a serializer; the host checks this at startup.
* **`EffectDelivery`.** Every handler receives a stable effect id and the attempt number, so its work can be made safe to repeat. `IEffectHandler<TEffect>` is the form with no answer for `Decide`.
* **An entity starts, notifies and cancels a workflow.** Declare the workflow with `Uses(definition)` on the entity definition, then call `Start`, `Notify` or `Cancel` on a decision. A start is recorded and delivered until the workflow answers. `Notify` and `Cancel` are recorded and attempted once; their answer and their give-up become commands through `Uses(definition, told:, gaveUp:)`. A workflow that is not hosted fails the host at startup.
* **`Resume` is removed.** A recorded effect comes back by itself. "Ask again after a crash" is an arm in `Decide` on the `Interrupted` give-up.
* **Akka.Reminders is required** for an entity that has effect handlers or uses a workflow; the host refuses to start without it. Timer keys that start with `durable-` are reserved for the library.
* **A finished workflow cancels its wake reminder.**
* **`VerifyMessageSerialization` works with C# union events** on Akka.NET 1.6.0-beta7. It stays off by default.
* **TestKit.** `DeciderScenario` adds `WhenGaveUp`, `ThenStarted`, `ThenNotified` and `ThenCancelled`, and loses `Resumed()`. `DurableEntityTestKit` adds `PendingEffects`.
* **`Run`, `Tell` and `Send` are unchanged.** They are light and not recorded. If delivery must be guaranteed, write an effect handler and use `Execute`.
* **The repository builds with the stable .NET 11 SDK.**
Not yet implemented: retry policies on workflow work, `Again` with a delay, delivery of a workflow's outcome to the entity or workflow that started it, child workflows, a scenario test kit for workflows, state and event subscriptions, entity contracts and proxies, source generation, and snapshot retention tests.
Known issues: 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. A reader of the journal now sees the library's own records between an entity's events, and `StateSnapshot.SequenceNr` counts them. A snapshot written by this release cannot be read by 0.1.0-alpha.3.