PicoActor 2026.4.1

dotnet add package PicoActor --version 2026.4.1
                    
NuGet\Install-Package PicoActor -Version 2026.4.1
                    
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="PicoActor" Version="2026.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="PicoActor" Version="2026.4.1" />
                    
Directory.Packages.props
<PackageReference Include="PicoActor" />
                    
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 PicoActor --version 2026.4.1
                    
#r "nuget: PicoActor, 2026.4.1"
                    
#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 PicoActor@2026.4.1
                    
#: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=PicoActor&version=2026.4.1
                    
Install as a Cake Addin
#tool nuget:?package=PicoActor&version=2026.4.1
                    
Install as a Cake Tool

PicoActor

AOT-compatible, in-memory Actor framework with Event Sourcing for .NET. Lightweight, zero-reflection, designed for agentic AI systems and workflow orchestration. Runs under NativeAOT and trimming.

CI NuGet License: MIT

English | 简体中文 | 日本語 | Español | Português | 繁體中文 | 한국어 | Français | Deutsch | Русский


Computational Model

┌─────────────────────────────────────────────────┐
│                  ActorSystem                     │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐         │
│  │ Counter │  │  Agent  │  │ Session │  ...     │
│  │ (Actor) │  │ (Actor) │  │ (Actor) │         │
│  └────┬────┘  └────┬────┘  └────┬────┘         │
│       │            │            │               │
│       ▼            ▼            ▼               │
│  ┌─────────────────────────────────────────┐    │
│  │            IEventStore                   │    │
│  │   AppendAsync / LoadAsync               │    │
│  └─────────────────────────────────────────┘    │
└─────────────────────────────────────────────────┘

Each actor owns a mailbox (in-memory Channel<Envelope>), a UUID v7 identity, and a single-threaded consumption loop. Commands are delivered via Send (fire-and-forget) or AskAsync (request-reply).

PicoActor is message-driven: every interaction is a message. Commands (ICommand) are directed messages delivered through the mailbox (1:1, optional response); domain events (IDomainEvent) are broadcast messages published through PicoMediator (1:N, no response). Events are messages too — the cross-aggregate collaboration pattern is a single loop: event → subscriber → translated command → mailbox. There is no other way to interact with an actor.

Event-Sourced actors follow Persist-then-Mutate: OnMessageAsync → RaiseEvent → Persist to IEventStore → Mutate state. State is only mutated after successful persistence — the in-memory state is always consistent with the event stream.


Why PicoActor

Concern Existing Options PicoActor
AOT / Trimming ❌ Akka.NET, Proto.Actor, Orleans all require reflection ✅ Full NativeAOT support
Event Sourcing ❌ Proto.Actor, Orleans lack built-in ES ✅ Persist-then-Mutate, automatic rollback
Dependency Size ❌ Akka.NET (8+ packages), Orleans (10+ packages) ✅ 1 package — PicoActor (brings PicoActor.Abs plus the PicoDI/PicoLog/PicoMediator abstractions); add PicoMediator for domain-event subscribers and PicoDI for container wiring
DI Integration ❌ Tied to Microsoft.Extensions.DI ✅ Native PicoDI, zero-reflection resolution
netstandard2.0 ⚠️ Akka.NET / Proto.Actor only ❌ net10.0-only (PicoMediator runtime requires net10.0+)
Learning Curve ❌ Steep — supervision trees, clustering, remoting ✅ Minimal — actors + events + mailbox

Quick Start

dotnet add package PicoActor
using PicoActor;
using PicoActor.Abs;

// 1. Setup
var store = new InMemoryEventStore();
var system = new ActorSystem(new ActorSystemOptions { EventStore = store });

// 2. Register actor factories
system.Register<Counter>(
    createFactory: cmd => cmd switch
    {
        CreateCounter c => new Counter(c),
        _ => throw new InvalidOperationException()
    },
    rebuildFactory: () => new Counter()
);

// 3. Create, message, stop, rebuild
var counter = await system.CreateAsync<Counter>(new CreateCounter(42));
system.Send(counter.Id, new Increment(5));
var value = await system.AskAsync<int>(counter.Id, new GetValue());
await system.StopAsync(counter.Id);
var rebuilt = await system.GetAsync<Counter>(counter.Id);

Module Details

PicoActor.Abs — Core Abstractions

Targets net10.0 (PicoMediator runtime + generated bridge code require net10.0+). Contains all interfaces and base classes.

Type Role
IActor Base interface — provides Id (UUID v7)
IActorSystem Runtime contract — Register, CreateAsync, FindAggregateIds, GetAsync, Send, AskAsync, StopAsync, RequestStop, StopAllAsync, ExecuteSaga, ResumeInterruptedSagasAsync
ICommand Marker interface for commands
IDomainEvent Marker interface for domain events
IEventSourcedActor Optional interface — Version, ReplayEvents, CommitEvents
IEventStore Persistence contract — AppendAsync (optimistic concurrency), LoadAsync, PeekFirstAsync
Actor Abstract base — mailbox, consumption loop, SignalReady, StopAsync
EventSourcedActor ES base — RaiseEvent, Mutate, Persist-then-Mutate pipeline
SagaActor Finite-life ES coordinator — framework terminal events (SagaCompleted/SagaFailed), auto-stop, explicit batch recovery via ResumeInterruptedSagasAsync
IDomainEventSubscriber<TEvent> Subscriber contract — typed envelope + ICommandSender; auto-registered by PicoActor.Gen (declare-and-subscribe)
DomainEventEnvelope / DomainEventEnvelope<TEvent> Context envelope — ActorId, Version, Event (transport / typed delivery)
ICommandSender Narrow command port for handlers — Send, AskAsync, ExecuteSaga
Envelope Internal — wraps ICommand with optional TaskCompletionSource
ActorOutputEvent Outbound notification — Type, Data, optional TurnId
ConcurrencyException Thrown by IEventStore on version mismatch

PicoActor — Runtime

Targets net10.0, AOT-compatible.

Type Role
ActorSystem Default IActorSystem — ConcurrentDictionary registry, factory registration, message routing
InMemoryEventStore In-memory store — per-stream lock, optimistic concurrency (ConcurrentDictionary-backed)
ActorConfig Configuration POCO — bind from PicoCfg
ActorSystemOptions Options — required EventStore, optional Logger, optional DomainEventPublisher; consumed by the ActorSystem constructor
MediatorDomainEventPublisher Default IDomainEventPublisher — publishes DomainEventEnvelope per event with per-event isolation
PicoActorDiExtensions AddPicoActor() extension method for PicoDI

Actor (Non-ES)

Inherit from Actor for pure in-memory operational actors.

public sealed class EchoActor : Actor
{
    protected override ValueTask<object?> OnMessageAsync(ICommand command)
        => new ValueTask<object?>(command);
}

Event-Sourced Actor

Inherit from EventSourcedActor. Override OnMessageAsync to call RaiseEvent, and Mutate to apply state changes.

public sealed class Counter : EventSourcedActor
{
    private int _value;

    public Counter(CreateCounter cmd) : base(cmd) { }
    public Counter() { }

    protected override ValueTask<object?> OnMessageAsync(ICommand command)
    {
        switch (command)
        {
            case Increment i:
                RaiseEvent(new CounterIncremented(i.Delta));
                break;
            case GetValue:
                return new ValueTask<object?>(_value);
        }
        return default;
    }

    protected override void Mutate(IDomainEvent @event)
    {
        switch (@event)
        {
            case CounterIncremented e: _value += e.Delta; break;
        }
    }
}

SagaActor (Finite-Life Coordinator)

SagaActor extends EventSourcedActor — for cross-aggregate operations with a finite lifecycle. Unlike a regular EventSourcedActor whose mailbox runs forever, a SagaActor auto-stops after the framework persists its terminal event.

Terminal state is framework-generated: MarkComplete(result) appends a SagaCompleted(result) event to the same batch as your business events (atomic append — completion and persistence cannot diverge); an uncaught business exception appends SagaFailed(reason) ("ExceptionType: message", truncated to 512 chars) and propagates SagaExecutionException(Id, Reason) to the caller. Subclasses never raise or handle these events — Mutate only sees business events.

public sealed class OrderSaga : SagaActor
{
    private int _step;
    private Guid _orderId;

    public OrderSaga() { }  // Parameterless — commands via mailbox

    protected override async ValueTask<object?> OnMessageAsync(ICommand command)
    {
        if (command is PlaceOrder cmd)
        {
            var orderId = cmd.OrderId;   // local — state changes only via Mutate
            if (_step < 1) RaiseEvent(new OrderPlaced(orderId));
            if (_step < 2) RaiseEvent(new PaymentReserved(orderId));
            MarkComplete(orderId);  // framework appends SagaCompleted(orderId) atomically
            return orderId;
        }
        return null;
    }

    protected override void Mutate(IDomainEvent @event)
    {
        switch (@event)
        {
            case OrderPlaced e: _step = 1; _orderId = e.OrderId; break;
            case PaymentReserved: _step = 2; break;
            // SagaCompleted/SagaFailed are filtered by the framework — never here
        }
    }

    protected override async ValueTask ResumeAsync()
    {
        // Re-evaluate after crash recovery. Idempotent — _step guards skip
        // completed steps; may AskAsync external aggregates or no-op to wait.
        await OnMessageAsync(new PlaceOrder(_orderId));
    }
}

Lifecycle:

CreateAsync(cmd) → mailbox processes cmd → MarkComplete(result)
→ framework appends SagaCompleted(result) in the same flush batch (atomic)
→ ProcessAsync returns → auto-stop (fire-and-forget) → removed from registry

Business failure: OnMessageAsync/ResumeAsync throws → framework discards uncommitted business events, appends SagaFailed(reason), auto-stops, and faults the Ask caller with SagaExecutionException(Id, Reason). Infrastructure failures (store down) are not terminal — events roll back and the saga stays Running. If persisting SagaFailed itself fails (store still down), no terminal state is produced and the original exception propagates to the caller.

Crash recovery: GetAsync replays events → the framework restores Completed/Failed from SagaCompleted/SagaFailed (terminal sagas stay dead — GetAsync returns null). Sagas without a terminal event get ResumeAsync() called, and if that reaches the terminal state the framework persists SagaCompleted in the same flush. Recovery is explicit pull, not background magic.

Explicit batch recovery:

var results = await system.ResumeInterruptedSagasAsync<OrderSaga>(
    nameof(OrderPlaced));

foreach (var r in results)   // SagaResumeResult(Id, Status, Reason?)
{
    // SagaResumeStatus.Completed | Failed (Reason) | Running
}

ResumeInterruptedSagasAsync<TSaga>(firstEventType, match?) enumerates sagas by first-event type name, single-flights each through GetAsync, and returns the post-resume classification: Completed / Failed (with the framework recovered reason) / Running (still waiting for external input). Already terminal sagas are never resurrected; already-live sagas are classified in-place. Idempotent and safe to retry (single-flight per id); a store failure fails fast so the caller can retry the whole batch.

Process manager pattern: the same base class covers process managers — external events are translated to commands by an application-level event handler (e.g. a PicoMediator subscriber), which then sends them to the saga's mailbox. Events never go directly into actors; the saga only sees commands.

Convenience API:

var execution = await system.ExecuteSaga<OrderSaga, Guid>(new PlaceOrder(orderId));
// SagaExecution<Guid>(Id, Result) — saga auto-stops, no StopAsync needed

ExecuteSaga<TSaga, TResult>(command) does CreateAsync + AskAsync in one call. Returns SagaExecution<TResult>(Id, Result); on business failure it throws SagaExecutionException(SagaId, Reason) so the caller always gets the saga id.

Persist-then-Mutate Pipeline

OnMessageAsync → RaiseEvent (record only, no state change)
              → AppendAsync (persist to IEventStore)
              → Mutate (apply events to in-memory state)
              → Reply to caller (only after success)

If AppendAsync fails, uncommitted events are discarded and Version is rolled back. The actor is not poisoned — the next message processes cleanly. Mutate must be a pure function that never throws; if it does, the batch is already durable and the actor is stopped (removed from the registry) instead of continuing with untrusted state.

Messaging: Ask vs Send

Pattern Method Semantics
Request-Reply AskAsync<TResult>(id, command) Returns result after message processing
Fire-and-Forget Send(id, command) No reply; failures routed to UnhandledErrorHandler (stderr without a logger); throws for unknown actors / closed mailbox (stop race)

OutputChannel

Actors broadcast ActorOutputEvent messages to external subscribers:

counter.OutputWriter = channel.Writer;
// Inside OnMessageAsync:
WriteOutput("Incremented", data: "Delta=5");

Spawn

Actors create child actors via System reference:

protected override async ValueTask<object?> OnMessageAsync(ICommand command)
{
    if (command is SpawnChild s)
    {
        var child = await System!.CreateAsync<Worker>(s.Cmd);
        return child.Id;
    }
    return default;
}

Module Integration

PicoDI

var container = new SvcContainer();
container.AddPicoActor();  // Registers IActorSystem + InMemoryEventStore

// Custom event store
var store = new InMemoryEventStore();
container.AddPicoActor(store);

// From PicoCfg
var cfg = CfgBind.Bind<ActorConfig>(configuration, "Actor");
container.AddPicoActor(cfg);

IActorSystem is a Singleton. If ILoggerFactory is registered, a logger is automatically injected.

Custom IEventStore

public sealed class PostgresEventStore : IEventStore
{
    public ValueTask<ulong> AppendAsync(Guid actorId, ulong expectedVersion,
        IReadOnlyList<IDomainEvent> events) { /* INSERT with concurrency check */ }

    public ValueTask<IReadOnlyList<IDomainEvent>> LoadAsync(Guid actorId)
    { /* SELECT ordered by version */ }

    public ValueTask<IDomainEvent?> PeekFirstAsync(Guid actorId)
    { /* SELECT first event (recovery enumeration) */ }
}

Event Outflow (PicoMediator)

IDomainEvent : IEvent — domain events are first-class PicoMediator notifications. After persist+mutate, the framework publishes them as context envelopes through the IDomainEventPublisher hook; replay (recovery) never republishes.

MediatorDomainEventPublisher is the out-of-the-box adapter: it wraps each event in a DomainEventEnvelope(actorId, version, event) and publishes it via Publish<DomainEventEnvelope> (compile-time generic, AOT-safe) with per-event isolation — one failing subscriber never blocks later events.

Subscribing to Domain Events (declare-and-subscribe)

Event handlers are plain classes implementing IDomainEventSubscriber<TEvent> — PicoActor.Gen (embedded in PicoActor.Abs) scans and auto-registers them; no manual wiring. The handler receives a typed envelope carrying the source aggregate context (ActorId, Version) plus a narrow ICommandSender port:

Handlers may be declared as classes or records. An implementation with no accessible (public or internal) instance constructor fails the build with PICA001 instead of emitting broken registration code.

public sealed class OrderPaidHandler : IDomainEventSubscriber<OrderPaid>
{
    public ValueTask Handle(DomainEventEnvelope<OrderPaid> envelope, ICommandSender sender, CancellationToken ct)
    {
        sender.Send(envelope.Event.OrderId, new ShipOrder(envelope.Event.OrderId));
        return default;
    }
}

// Wiring: AddPicoMediator registers IMediator; AddPicoActor() auto-detects it in the // ActorSystem factory and wires event outflow (lazy — Scoped-compatible). var container = new SvcContainer(); container.AddPicoMediator(); // declare-and-subscribe: auto-registers subscribers container.AddPicoActor(); // auto-wires MediatorDomainEventPublisher container.Build(); await using var scope = container.CreateScope(); var system = (IActorSystem)scope.GetService(typeof(IActorSystem));

// Explicit wiring for custom publishers: AddPicoActor(IPublisher) // (the publisher instance must be available before Build()).


> **Local development (ProjectReference):** analyzers do not flow through
> `ProjectReference` chains — project consumers must add a direct reference to
> `PicoActor.Gen` (`<ProjectReference Include="..\src\PicoActor.Gen\PicoActor.Gen.csproj"
> OutputItemType="Analyzer" />`, mirroring `tests/PicoActor.Tests`). NuGet
> consumers get the generator automatically via `PicoActor.Abs`'s
> `buildTransitive` props — no extra reference needed.

Events flow out as envelopes through PicoMediator after persist+mutate; replay
never publishes. Handler failures never affect the actor (per-event isolation).
Translation loops (event → command → event) are intended; keep handlers
idempotent and bounded.

> **Breaking change:** direct `ISubscriber<TEvent>` (PicoMediator)
> subscribers no longer receive PicoActor domain events. Migrate to
> `IDomainEventSubscriber<TEvent>`; the envelope's `ActorId`/`Version` replace
> any manually embedded aggregate id. Custom publishers
> (`AddPicoActor(IPublisher)`) now receive `DomainEventEnvelope` instances
> instead of raw events — adapt `Publish<TEvent>` implementations accordingly
> (only the observed payload shape changed; the actor pipeline is unaffected).
> `IEventStoreEnumerator.ListAggregateIds(string)` became
> `ListAggregateIdsAsync(string)` (async) — custom enumerators must update the
> signature.

Notes:
- **Event → command translation is the subscriber's (business-layer) job** —
  PicoActor only publishes; commands enter actors exclusively via the mailbox.
- Publish runs **after persist+mutate** — a failed publish never corrupts
  actor state; events are already durable.
- Recovery is silent: replay never republishes events.
- Framework events (`SagaCompleted`/`SagaFailed`) are typed-subscribable
  like any other event (Abs targets net10.0).
- **Auto-wiring is safe from any resolving scope**: since PicoDI 2026.8.1 (E1)
  singleton factories run against the container-internal root scope, the
  auto-wired IMediator lives until container disposal.
- **Never `AskAsync` the publishing aggregate from its own publish path** — the
  source mailbox is busy flushing the event, so the request would self-deadlock.
  Query read-side projections (separate actors) instead; `Send` to the source
  aggregate is safe (fire-and-forget).
- `Register<T>` must be called once per actor type: a duplicate registration
  now throws instead of silently replacing the first factory.
- `Register<T>` rejects types the runtime cannot serve: a non-`Actor` implementation, or an `IEventSourcedActor` that does not derive from `EventSourcedActor` (persistence is wired for the concrete bases only).
- `StopAsync`/`RequestStop` remove the actor from the registry first, then drain
  the messages already buffered in its mailbox (graceful stop); messages sent
  after the stop fail with `KeyNotFoundException` (or `InvalidOperationException` when the mailbox was already closed — the stop-race window).

---

## Design Philosophy (克制 / 专注 / 优雅 / 高效)

| Principle | In Practice |
|-----------|------------|
| **克制 (Restraint)** | No distributed consensus, no supervision trees — just actors and events. |
| **专注 (Focus)** | Single-threaded per-actor. One message at a time. |
| **优雅 (Elegance)** | Persist-then-Mutate: state changes only after persistence. Rollback is automatic. |
| **高效 (Efficiency)** | AOT-compatible, zero reflection, `net10.0` abstractions. |

---

## Use Cases

- **Agentic AI systems** — each AI agent is an actor with conversation state
- **Workflow orchestration** — SagaActor coordinates cross-aggregate operations with framework terminal events, auto-stop, and explicit batch recovery
- **Game server state** — event-sourced actors for player/game state
- **IoT device state** — in-memory actors with periodic snapshotting

---

## Packages

| Package | Target | Description |
|---------|--------|-------------|
| [PicoActor.Abs](https://www.nuget.org/packages/PicoActor.Abs) | `net10.0` | Core abstractions: `IActor`, `IActorSystem`, `ICommand`, `IDomainEvent`, `IEventStore`, `Actor`, `EventSourcedActor`, `SagaActor` — plus subscription types (`IDomainEventSubscriber<TEvent>`, `DomainEventEnvelope`, `ICommandSender`) and the embedded `PicoActor.Gen` analyzer (declare-and-subscribe) |
| [PicoActor](https://www.nuget.org/packages/PicoActor) | `net10.0` | Runtime: `ActorSystem`, `InMemoryEventStore`, `MediatorDomainEventPublisher` (envelope event outflow), PicoDI integration (`AddPicoActor` auto-wires IMediator + ICommandSender) |

**Required packages by feature** (verified against the package nuspec):

| Need | Packages to add |
|------|-----------------|
| Actors + event sourcing only | `PicoActor` (`PicoActor.Abs`, `PicoDI.Abs`, `PicoLog.Abs`, `PicoMediator.Abs` come with it) |
| Declaring `IDomainEventSubscriber<TEvent>` handlers | + `PicoMediator` (the generated bridge calls `MediatorAutoSubscriptionRegistry`) |
| PicoDI wiring / event outflow (`SvcContainer`, `AddPicoMediator`) | + `PicoDI`, `PicoMediator.DI` |

---

## Comparison

| Feature | PicoActor | Akka.NET | Proto.Actor | Orleans |
|---------|:---:|:--:|:--:|:--:|
| In-memory only | ✅ | ✅ | ✅ | ❌ |
| AOT / Trimming | ✅ | ❌ | ❌ | ❌ |
| Event Sourcing | ✅ | ✅ | ❌ | ❌ |
| netstandard2.0 abstractions | ❌ | ✅ | ✅ | ❌ |
| PicoDI integration | ✅ | ❌ | ❌ | ❌ |
| Persist-then-Mutate | ✅ | ❌ | ❌ | ❌ |
| Distributed / Clustering | ❌ | ✅ | ✅ | ✅ |
| Single-threaded per actor | ✅ | ✅ | ✅ | ❌ |
| Packages | 1 (+2 optional) | 8+ | 3+ | 10+ |

---

## License

MIT — see [LICENSE](LICENSE).
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
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
2026.4.1 120 9/18/2026
2026.4.0 86 9/18/2026
2026.3.2 125 9/9/2026
2026.3.1 120 8/24/2026
2026.3.0 113 8/24/2026
2026.2.0 117 8/23/2026
2026.1.4 116 8/8/2026
2026.1.3 114 8/6/2026
2026.1.2 113 8/6/2026
2026.1.1 112 8/6/2026
2026.1.0 375 7/18/2026