Aaron.Akka.Durable.TestKit
0.1.0-alpha.7
See the version list below for details.
dotnet add package Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.7
NuGet\Install-Package Aaron.Akka.Durable.TestKit -Version 0.1.0-alpha.7
<PackageReference Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.7" />
<PackageVersion Include="Aaron.Akka.Durable.TestKit" Version="0.1.0-alpha.7" />
<PackageReference Include="Aaron.Akka.Durable.TestKit" />
paket add Aaron.Akka.Durable.TestKit --version 0.1.0-alpha.7
#r "nuget: Aaron.Akka.Durable.TestKit, 0.1.0-alpha.7"
#:package Aaron.Akka.Durable.TestKit@0.1.0-alpha.7
#addin nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.7&prerelease
#tool nuget:?package=Aaron.Akka.Durable.TestKit&version=0.1.0-alpha.7&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. Callers can wait for an entity's state, or watch it, without polling.
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. Issuing an invoice runs a VerifyRecipient effect, and its answer comes back as a command. This is the state that handles it:
public static InvoiceDecision Decide(InvoiceState state, InvoiceCommand command, InvoiceContext ctx) =>
state switch
{
// ... Draft: IssueInvoice persists VerificationStarted and issues the VerifyRecipient effect.
Verifying v => command switch
{
// The answer to VerifyRecipient arrives as a command.
// v.Request is the IssueInvoice we are still answering.
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)),
RecipientRejected r =>
ctx.Persist(new VerificationFailed(r.Reason))
.Reply(v.Request, new InvoiceRejected(r.Reason)),
_ => NotHere(command, ctx), // refused or ignored: it does not belong here
},
// ... Issued and Paid have the same shape.
};
Every state looks like this: see the complete invoice.
VerifyRecipient is the effect: a plain record, journaled in the same write as VerificationStarted. The definition puts the invoice together. Its GaveUp is the command Decide gets when the directory gives no answer:
[AkkaSerializable(Manifest = "verify-recipient-v1")]
public sealed record VerifyRecipient([property: AkkaField(0)] string Recipient) : IInvoiceProtocol;
public static readonly EntityDefinition<InvoiceState, InvoiceCommand, InvoiceEvent> Definition =
new(new EntityTypeName("invoices"), _ => new Draft(), Decide, Evolve,
GaveUp: g => new RecipientRejected($"directory: {g.Reason}"));
The only impure code is the effect's 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);
Task<StateSnapshot<TState>> WaitForStateAsync(
Func<TState, bool> until,
CancellationToken cancellationToken);
IAsyncEnumerable<StateSnapshot<TState>> WatchStateAsync(
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.
To react to a change, do not read the state in a loop. Wait for the state, or watch it:
// In a request handler: wait at most five seconds for the payment.
using var limit = CancellationTokenSource.CreateLinkedTokenSource(ct);
limit.CancelAfter(TimeSpan.FromSeconds(5));
// Throws OperationCanceledException at the limit.
var paid = await invoices.Get(id).WaitForStateAsync(s => s is Paid, limit.Token);
// In a status stream: the current state first, then each new one.
await foreach (var snapshot in invoices.Get(id).WatchStateAsync(ct))
await WriteStatusAsync(snapshot.State, snapshot.SequenceNr, ct);
You get the current state first, then a snapshot after each write that holds events, once its Run effects have finished. Every snapshot carries the journal's sequence number as the version of the state, and a slow reader of the stream gets the newest one. This is a live, best-effort push held in memory: nothing is journaled, a state can be missed while the entity restarts or moves, and a reaction that must happen belongs in an effect. Workflow refs have the same two calls, and WaitForOutcome is built on the wait. See Waiting for a state, and watching it.
You do not need to build an outbox. Akka.Durable has one.
// In Decide:
ctx.Persist(new PaymentRecorded(p.Amount))
.Execute(new PublishPayment(ctx.EntityId, p.Amount));
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. A workflow's work takes the same policies from the same registration: see Workflows.
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. |
| Waiting for a state, and watching it | WaitForStateAsync and WatchStateAsync, and what a state subscription guarantees. |
| Workflows | Finite processes that start once and end with an outcome: states, work, retry policies on 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.7)
- Akka.Hosting.TestKit (>= 1.6.0-beta7)
- xunit.v3.assert (>= 4.0.3)
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 |
**Breaking change: `IEffect<TCommand>` is removed.** An effect that an entity issues with `Execute` is now a plain record. The command for a give-up comes from one function on the entity's definition, as it does for a workflow. Akka.Durable is still a proof of concept, not an official Akka.NET project.
* **Removed: `IEffect<TCommand>`** and its `GaveUp` method. `Execute` takes any record.
* **Added: `GaveUp` on `EntityDefinition`.** It is an optional, named argument: `new(name, initialState, Decide, Evolve, GaveUp: g => new YourCommand(g.Reason))`. It is one function for all the effects of the entity. `WorkGaveUp.Work` is the effect that got no answer, so the function, or `Decide`, can tell the effects apart. A workflow's definition has a `GaveUp` with the same name and shape.
* **A missing `GaveUp` fails early.** The host does not start when an entity type has an effect handler that answers with its command type and no `GaveUp`. The error names the entity type and the argument to add. When every handler of the entity has no answer, the host cannot see which entity issues the effect. The first `Execute` is then refused before anything is written, with the same message, and the entity restarts.
* **A handler that answers with another command type** is refused at the first `Execute`, before anything is written. Before, the host refused to start. The host can no longer tell this handler from the handler of another entity.
* **The start-up checks no longer need the interface.** An entity host checks the serializer of every effect type whose handler answers with the entity's command type or has no answer. The check for Akka.Reminders uses the same handlers.
* **TestKit.** `WhenGaveUp` decides the command that the definition's `GaveUp` returns. `When` fails the test when a decision issues an effect and the definition has no `GaveUp`.
**What you change from 0.1.0-alpha.6:** two steps.
1. Remove `IEffect<TCommand>` and the `GaveUp` method from each effect record.
2. Add `GaveUp:` to the entity's definition. For an entity with more than one effect, the function is a `switch` on `g.Work`, with the body of each old method in one arm.
```csharp
// Before
public sealed record VerifyRecipient(string Recipient) : IInvoiceProtocol, IEffect<InvoiceCommand>
{ public InvoiceCommand GaveUp(WorkGaveUp g) => new RecipientRejected($"directory: {g.Reason}"); }
// After
public sealed record VerifyRecipient(string Recipient) : IInvoiceProtocol;
new(new EntityTypeName("invoices"), _ => new Draft(), Decide, Evolve, GaveUp: g => new RecipientRejected($"directory: {g.Reason}"));
```
Nothing stored changes. An effect is stored through its own serializer and manifest, and the interface was never part of that. An effect that was journaled before the upgrade gives up through the new `GaveUp`.