Aaron.Akka.Durable
0.1.0-alpha.2
dotnet add package Aaron.Akka.Durable --version 0.1.0-alpha.2
NuGet\Install-Package Aaron.Akka.Durable -Version 0.1.0-alpha.2
<PackageReference Include="Aaron.Akka.Durable" Version="0.1.0-alpha.2" />
<PackageVersion Include="Aaron.Akka.Durable" Version="0.1.0-alpha.2" />
<PackageReference Include="Aaron.Akka.Durable" />
paket add Aaron.Akka.Durable --version 0.1.0-alpha.2
#r "nuget: Aaron.Akka.Durable, 0.1.0-alpha.2"
#:package Aaron.Akka.Durable@0.1.0-alpha.2
#addin nuget:?package=Aaron.Akka.Durable&version=0.1.0-alpha.2&prerelease
#tool nuget:?package=Aaron.Akka.Durable&version=0.1.0-alpha.2&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-beta6 (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.Reminders (>= 1.6.0-beta5)
- Akka.Cluster.Hosting (>= 1.6.0-beta6)
- Akka.Serialization.V2 (>= 1.6.0-beta6)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Aaron.Akka.Durable:
| Package | Downloads |
|---|---|
|
Aaron.Akka.Durable.TestKit
Akka.Hosting.TestKit-based test infrastructure for Akka.Durable entities: a hosted spec base class with an in-memory journal and local reminders, plus Given/When/Then helpers for pure decider tests. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-alpha.2 | 0 | 11/10/2026 |
| 0.1.0-alpha.1 | 43 | 11/7/2026 |
Second alpha release of Akka.Durable. It adds durable workflows and effects whose result the entity decides on, and it changes parts of the 0.1.0-alpha.1 API. Akka.Durable is still a proof of concept, not an official Akka.NET project.
* **Durable workflows.** A workflow is a finite process that survives restarts. It starts once, moves through states that you define, and ends once with an outcome. A `WorkflowDefinition` holds five functions: `Start`, `Work`, `Decide`, `Cancel` and `GaveUp`.
* **Work is issued once.** When a workflow enters a state, the library issues that state's work one time and does not issue it again on its own. Work that fails, times out or is interrupted by a restart gives the workflow the fact that `GaveUp` returns, and `Decide` chooses what happens next.
* **Workflow hosting and refs.** `WithDurableWorkflow` hosts a workflow type, locally or in a shard region. `IWorkflowRefs<TInput, TFact, TOutcome>` hands out refs with `Start`, `Status`, `WaitForOutcome`, `Notify` and `Cancel`.
* **Facts from outside.** `Notify(fact)` sends a fact to a workflow, and `Cancel(reason, by)` asks it to stop. `Accepts<TMessage>` on the definition turns a message of another type into a fact. A fact that arrives while work is in flight is journaled and held, then decided when that work settles.
* **Workflow timers.** `ctx.Timer` in `Work` delivers a fact after a duration or at a date, and `ctx.All` gives one state several pieces of work and timers. Each workflow has one durable reminder, stored through Akka.Reminders, so a workflow type needs Akka.Reminders configured.
* **Commands from a workflow to an entity.** `ctx.Send` in `Work` sends a command to a durable entity, and the reply becomes one of the workflow's facts. The command is sent once. A send that gets no reply gives the workflow the give-up fact.
* **Effects with outcomes.** `Execute(effect)` issues a plain effect record, and the `IEffectHandler<TEffect, TCommand>` registered for it in DI runs it. The handler returns one of the entity's commands, which goes through `Decide`. The entity handles other commands while the handler runs, and a handler that throws or times out is logged and no command arrives.
* **`Run` is best effort.** `Run` no longer takes `onSuccess` or `onFailure`, and no result returns to the entity. A failure or timeout is logged and the decision continues. Use `Execute` when the entity needs the result.
* **`Resume` after a restart.** `EntityDefinition.Resume` lists the effects that are still pending for a recovered state. It runs once after recovery, before the first command, and it may not return events.
* **Entity to entity.** `Send<TOther>(id, command)` sends a command to another durable entity, and `Accepts<TMessage>` on the entity definition turns the reply into one of the sender's commands. `Send` replaces `Tell` to an entity ref from a decision, because `IEntityRef` no longer implements `ICanTell`.
* **Typed replies.** A command declares its reply type with `IReplies<TCommand, TReply>`. `Ask` infers the reply type from the command, and `Reply(request, reply)` takes the command that declared the reply. A command without the interface can be sent with `Tell` only.
* **`DecisionContext` has three type arguments.** It is now `DecisionContext<TState, TCommand, TEvent>`. `Decide` still receives the state as a parameter.
* **A stop waits for `Execute` work.** An entity that is asked to stop while an `Execute` handler is running finishes that work and decides its outcome before it stops. This applies to idle passivation in local and sharded hosting.
* **`ShardedHosting` replaces `ShardOptions`.** `WithDurableEntity` and `WithDurableWorkflow` take a `ShardedHosting` record with `Role`, `StateStoreMode`, `RememberEntities` and `RememberEntitiesStore`. Akka.Durable sets the stop message and idle passivation itself. Use `EntityOptions.PassivateIdleAfter` for the idle timeout.
* **One command type per entity type.** `WithDurableEntity` rejects a second entity type that uses a command type another entity type already uses. The error names both entity types and the command type.
* **Refused messages are logged.** An entity logs a warning when it receives a message that is not one of its commands and that its definition does not accept. The warning names the entity type, the entity ID and the message type.
* **TestKit.** `DeciderScenario` adds `Resumed()`, `ThenExecuted` and `ThenSent`. `DurableEntityTestKit` adds a `WorkflowRef` helper for hosted workflow tests.
* **Documentation.** The README is now a short overview. The guide is in `docs/`, one topic per page, with pages for workflows and workflow rules.
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 until akkadotnet/akka.net#8910 is fixed.
Known issue: on Akka.NET 1.6.0-beta6, commands that arrive while a decision's asynchronous effect (`Run`, `Schedule` or `CancelTimer`) is running can be handled out of order. This comes from Akka.Persistence, and a fix is being prepared upstream.