Reaktor Docs Architecture Final actor model Updated May 21, 2026

Implemented primitive

Reaktor Actors are graph nodes with a serialized mailbox

The final local actor model keeps Reaktor small: Graph stays the runtime and supervisor, ActorNode stays a normal Node, ActorKey names the identity, ActorRef is the mutation handle, and each actor owns an exclusive ObjectStore. BestBuds chat now runs through this model.

No ActorSystemGraph already owns lifecycle, dependency scope, coroutine scope, ports, and node attachment.
Mailbox ContractAll external mutation enters through ordered send or ask messages.
Normal NodesActors live in the existing node collection and participate in consumes/provides wiring.
Durable ReadyThe backend adapter can map one actor key to one Cloudflare Durable Object instance.

1. Implementation Status

The actor primitive is implemented in reaktor-graph, backed by concurrent graph node storage in reaktor-graph-port, and exercised by BestBuds chat.

AreaStatusImportant files
Actor primitiveImplementedreaktor-graph/.../core/node/ActorNode.kt
Graph supervisionImplementedreaktor-graph/.../core/Graph.kt
Concurrent node collectionImplemented with Collection API preservedreaktor-graph-port/.../graph/PortGraph.kt
BestBuds chat migrationImplemented as ChatActorbestbuds/modules/app/.../data/interactors/ChatActor.kt
Actor testsImplementedActorNodeIntegrationTest.kt, ChatActorTest.kt
Cloudflare Durable Object adapterDesigned, not implemented in this passFuture reaktor-cloudflare actor bridge
Shipped rule: actors are not a second runtime. They are nodes with a mailbox, lifecycle hooks, state helpers, and a provider port registered from their key.

2. Principles

The actor model follows the same standard as the route-binding improvement from dispatch(Push(edge, payload)) to edge.push(payload): maximum power behind a small, expressive API.

state has one owner
events go to the owner
the owner serializes mutation
the owner emits state
views render state
services transport data
ports expose capabilities

What actors are for

  • Mutable state with concurrent event sources.
  • WebSocket sessions, active rooms, retries, timers, and ordered side effects.
  • Backend entities where one logical identity owns state.
  • Workflow/session coordinators that need supervision.

What actors are not for

  • Pure repositories.
  • Stateless service clients.
  • Screen-only Compose state.
  • Adding another DI, graph, or service framework.

3. Core Model

Keep the public set tiny. Everything else should be an adapter, helper, or code generation target.

ConceptRoleDesign constraint
ActorKey<M>Logical actor identity and message type.Key is the durable identity. Activation is just a node instance.
ActorRef<M>Public send, trySend, ask handle.No public mutable actor state should bypass the mailbox.
Reply<R>Ask bridge.Typed replies stay in the message, not in a global callback registry.
ActorNode<M>Node with mailbox, lifecycle, store, timers, and receive.Subclass owns implementation; callers talk through messages or narrow convenience APIs that enqueue messages.
MailboxPolicyCapacity and overflow.Default is bounded and suspending to avoid hidden overload.
ActorDirectiveFailure response.Resume by default; restart, stop, and escalate are explicit.

The critical contract

ActorRef.send(message)
  -> mailbox accepts the message
  -> actor loop receives messages FIFO
  -> receive(message) mutates owned state
  -> state flows, object state, services, and ports observe the result

4. API Shape

Actor code should feel like normal Reaktor code. The key is the only actor-specific identifier.

object ChatActors {
    val Session = actorKey<ChatMsg>("bestbuds.chat.session")
}

sealed interface ChatMsg {
    data class Open(val user: User, val chatId: String, val existing: Chat?) : ChatMsg
    data class IncomingSocketText(val text: String) : ChatMsg
    data class FetchChats(val reply: Reply<Result<List<Chat>>>) : ChatMsg
}

class ChatActor(graph: Graph) : ActorNode<ChatMsg>(
    graph = graph,
    key = ChatActors.Session,
) {
    override suspend fun receive(message: ChatMsg) {
        when (message) {
            is ChatMsg.Open -> openChatNow(message.user, message.chatId, message.existing)
            is ChatMsg.IncomingSocketText -> applyIncomingSocketText(message.text)
            is ChatMsg.FetchChats -> message.reply.complete(fetchChatsNow())
        }
    }
}

Graph declaration

class BestBuds : Graph(...) {
    init {
        provides(ChatActors.Session, ::ChatActor)
        autoWire()
    }
}

Node consumption

class ChatSessionInteractor(graph: Graph) : BasicNode(graph) {
    private val chatActor: ConsumerPort<ChatActor> by consumes(ChatActors.Session)

    fun open(user: User, chatId: String, existing: Chat?) {
        launch {
            chatActor.impl?.send(ChatMsg.Open(user, chatId, existing))
        }
    }
}

5. Lifecycle and Supervision

Actor lifecycle is node lifecycle. Attach starts the mailbox loop; save pauses it; restore starts it again; destroy closes it.

LifecycleActor behavior
RestoringStart loop if needed and run preStart.
AttachingLoop should already be running; start is idempotent.
SavingPause loop without closing the mailbox so Saving -> Restoring can resume.
DestroyingClose mailbox, cancel loop, close ports and node scope.

Failure rule

A failed message does not kill the actor by default. The failed ask is completed exceptionally, onFailure runs, and the default directive is Resume. This is right for app actors where one bad user event should not destroy an active session.

protected open suspend fun onFailure(
    message: M,
    error: Throwable,
): ActorDirective =
    graph.actorSupervisorStrategy.onActorFailure(this, message, error)

6. Exclusive ObjectStore

Every actor has one exclusive object store derived from its key. Actor state should use named records inside that store instead of splitting the actor hierarchy into memory and persistent subclasses.

class RoomActor(graph: Graph) : ActorNode<RoomMsg>(graph, RoomActors.room("42")) {
    private val summary = state<RoomSummary>("summary")
    private val members = state<RoomMembers>("members")

    override suspend fun receive(message: RoomMsg) {
        when (message) {
            is RoomMsg.Join -> members.update { it + message.userId }
        }
    }
}
Persistence rule: in-memory fields are valid for fast runtime state, sockets, pending reactions, timers, and caches. Durable decisions must be written into the actor store.

7. Graph Wiring

The actor API deliberately stays close to Reaktor's port graph conventions.

provides(key, create)

Creates the actor if it does not exist, attaches it as a normal node, and returns the existing activation if it is already present.

consumes(key)

Registers a typed consumer port using the actor key name and the concrete actor type supplied by the property type.

Because the backing node store preserves Collection, existing code that iterates graph.nodes keeps working. Direct lookup is available through graph.node(id), while actor lookup is exposed through findActor.

8. BestBuds ChatActor

BestBuds chat is the first concrete product migration. It is a good actor because it receives concurrent events from UI, WebSocket, HTTP revalidation, timers, retries, pagination, reactions, and presence.

Before

  • Chat interactor directly exposed mutable flows and launched side jobs that could mutate state from outside a single ordered path.
  • WebSocket collection parsed and applied frames in collector context.
  • Chat list fetch logic was duplicated in list logic and chat logic.

After

  • ChatActor owns active chat state, messages, participants, failed ids, typing state, presence, sockets, and pending reaction aliases.
  • WebSocket frames become ChatMsg.IncomingSocketText before state changes.
  • Revalidation and group member fetches send actor messages before applying results.
  • Send, retry, discard, reaction, forward, load-older, typing, and close all enter through the mailbox.

Why this scales features

Feature pressureActor benefit
Fast incoming messages plus optimistic local sendsOne serialized state owner resolves temp ids, server echoes, failed ids, and ordering.
Reactions on temp messagesPending reactions are flushed when the real message id arrives.
Typing and presenceTimers send messages back into the actor rather than mutating state from timer jobs.
Pagination and revalidationOlder pages and fresh cache snapshots merge through one code path.
Backend room actor laterThe same message discipline maps to one chat room identity on server or Durable Object.

9. Cloudflare Alignment

Cloudflare Durable Objects are the backend reference point. Cloudflare describes Durable Objects as stateful serverless units with compute and storage, and its Actors package wraps Durable Objects with actor-oriented helpers.

Durable Object mapping

ActorKey.name
  -> Durable Object namespace id/name
  -> one backend actor instance
  -> actor-owned storage
  -> HTTP/RPC/WebSocket/alarm messages

Reaktor superset path

  • Local app actor: graph node plus mailbox.
  • Worker actor: Durable Object wrapper plus same receive discipline.
  • Storage: ObjectStore abstraction over local DB or Durable Object storage.
  • Timers: local coroutine timers now, Durable Object alarms in the backend adapter.
Important: distributed mesh actors remain out of scope. Durable Object deployability is a backend adapter target, not a reason to add location-transparent remoting to the actor core.

10. Invariants

  • There is no ActorSystem, ActorGraph, ActorRepository, or DurableActorNode.
  • Actor identity is the key. Activation is a graph node instance.
  • The raw mailbox channel is private.
  • Every external mutation enters through send, trySend, or ask, including socket and timer events.
  • Actor state helpers use the actor-owned ObjectStore.
  • Services remain transport endpoints. Repositories remain data access. Actors own serialized mutable behavior.
  • Convenience methods on concrete actors are acceptable only when they enqueue messages and do not mutate state directly.
  • Graph node collection remains the source of truth for actors and non-actors.

11. Next Work

HardeningShort term
  • Keep actor keys as stable node ids and stable actor store names.
  • Keep fatal failure status from being overwritten by normal stop status.
  • Add tests for lifecycle restart, actor lookup, timeout behavior, and duplicate key rejection.
  • Continue moving chat-adjacent state through ChatActor when it owns serialization semantics.
Backend adapterNext Reaktor Cloudflare pass
  • Create a Durable Object wrapper that deserializes requests into actor messages.
  • Back actor state() with Durable Object storage or SQLite-backed ObjectStore.
  • Map actor timers to Durable Object alarms.
  • Generate handler surfaces for typed ask/send endpoints.

12. Sources