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.
| Area | Status | Important files |
|---|---|---|
| Actor primitive | Implemented | reaktor-graph/.../core/node/ActorNode.kt |
| Graph supervision | Implemented | reaktor-graph/.../core/Graph.kt |
| Concurrent node collection | Implemented with Collection API preserved | reaktor-graph-port/.../graph/PortGraph.kt |
| BestBuds chat migration | Implemented as ChatActor | bestbuds/modules/app/.../data/interactors/ChatActor.kt |
| Actor tests | Implemented | ActorNodeIntegrationTest.kt, ChatActorTest.kt |
| Cloudflare Durable Object adapter | Designed, not implemented in this pass | Future reaktor-cloudflare actor bridge |
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.
| Concept | Role | Design 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. |
MailboxPolicy | Capacity and overflow. | Default is bounded and suspending to avoid hidden overload. |
ActorDirective | Failure 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.
| Lifecycle | Actor behavior |
|---|---|
Restoring | Start loop if needed and run preStart. |
Attaching | Loop should already be running; start is idempotent. |
Saving | Pause loop without closing the mailbox so Saving -> Restoring can resume. |
Destroying | Close 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 }
}
}
}
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
ChatActorowns active chat state, messages, participants, failed ids, typing state, presence, sockets, and pending reaction aliases.- WebSocket frames become
ChatMsg.IncomingSocketTextbefore 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 pressure | Actor benefit |
|---|---|
| Fast incoming messages plus optimistic local sends | One serialized state owner resolves temp ids, server echoes, failed ids, and ordering. |
| Reactions on temp messages | Pending reactions are flushed when the real message id arrives. |
| Typing and presence | Timers send messages back into the actor rather than mutating state from timer jobs. |
| Pagination and revalidation | Older pages and fresh cache snapshots merge through one code path. |
| Backend room actor later | The 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.
10. Invariants
- There is no
ActorSystem,ActorGraph,ActorRepository, orDurableActorNode. - Actor identity is the key. Activation is a graph node instance.
- The raw mailbox channel is private.
- Every external mutation enters through
send,trySend, orask, 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
- 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
ChatActorwhen it owns serialization semantics.
- 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.