Sharpening the graph framework toward an expressive, layered, React/Actor-grade runtime.
Premise. reaktor-graph is the most mature module in Reaktor and powers BestBuds in production. The skeleton — a sealed Node hierarchy over a typed port-graph with lifecycle, DI, navigation, and services — is sound. What the BestBuds codebase reveals is that everyday feature work spends most of its lines on ceremony: re-declaring RouteBindings, chaining .apply { exposePort(...) }, hand-rolling MutableStateFlow fields, and overriding activateGraphForRoute just to keep a tab selector in sync.
Goal of this document. Identify the precise places where the framework is under-abstracted (not over-abstracted) and propose a layered set of refinements that pull these patterns into the framework — without flattening into a one-shot mega-DSL. We want a runtime where the 80% case is one line and the 20% case is still wide open.
Every refinement in this document is judged against these four constraints. When they conflict, the order is the tiebreaker: an expressive primitive beats a simple one; a simple primitive beats a clever one; a clever one-shot API beats nothing only when it is also expressive.
Primitives must compose. A @Screen("/chat/:id") annotation that owns state, navigation, and IO is fast to type and impossible to bend. We prefer five thin orthogonal primitives that can be combined.
Two field-proven mental models: functions of state with declarative effects (React), and autonomous units exchanging messages with supervision (Akka/Erlang). Reaktor already has both ingredients half-formed; this plan finishes them.
Primitive → molecule → organism → template → system. Every layer is usable on its own. You can drop down a layer for power or up a layer for ergonomics — without rewriting.
A vanilla CRUD screen should be one DSL block. A custom render pipeline with shared mailbox routing should still be expressible, even if it takes more lines. Never force developers into the DSL.
Graph/Node/Port identities. The DSL is sugar on top of types we can still call directly.
Before proposing changes, lock in what we keep. The runtime has earned the following load-bearing decisions:
| Decision | Where it lives | Why we keep it |
|---|---|---|
Graph as the runtime scope (DI + lifecycle + coroutines + navigation + ports in one object) |
core/Graph.kt |
One place to attach, one place to tear down. Parent-child nesting cleanly models tabs/containers. |
Typed Port graph (ProviderPort<T>, ConsumerPort<T>, indexed by Type + Key) |
reaktor-graph-port/port/* |
Allows multiple ports of the same type per node; underpins autoWire(). |
| Sealed lifecycle state machine with cascading transitions | capabilities/LifecycleCapability.kt |
Predictable, debuggable. Actors hook in cleanly (Restoring/Attaching → loop start). |
| Cross-graph navigation that bubbles to the container owner | Graph.handleCrossGraphForward |
The right separation between in-tab and across-tab navigation; back-stacks stay layered. |
ActorNode<M> with mailbox + supervision + persistent ObjectStore |
core/node/ActorNode.kt |
Akka-grade primitive: sequential receive, ask with timeouts, restart/escalate directives, stable UUIDs by name. |
ServiceNode — HTTP handlers as provider ports |
service/Service.kt |
One model spans client and server. Swap an implementation without touching consumers. |
| Visitor + Selector + Traverser triplet for graph traversal | visitor/*, portgraph/visitor/* |
Decouples policy (DFS/BFS) from concern (introspection/serialization). |
The skeleton holds. Everything below adds tissue; nothing replaces a bone.
Findings from a sweep of bestbuds/modules/app and bestbuds/modules/engine, cross-referenced with the framework source.
RouteBinding subclasses are pure plumbingChatScreen.kt:77, BestBuds.kt:139RouteBinding subtype solely to hold cross-route NavigationEdge references in constructor parameters. The class never carries logic.exposePort chains repeat 3–10× per graphBestBuds.kt:389–411, WorkbenchGraph.kt:163–167.apply { exposePort(myPort) }. The intent ("I am exposed as T") is declarative; the call is imperative.by consumes<T>() bloat on actorsChatActor.kt:32–55consumes delegates plus getters. autoWire() would handle most of these, but the developer still has to spell them out because the actor needs typed references.MutableStateFlow sprawl in controllers/interactorsChatSessionStateInteractor.kt:27–46StateFlows with manual field = MutableStateFlow(...). There is no reducer composition, no useState-equivalent, no derived selectors.LaunchedEffect(payloadKey) with DisposableEffect(Unit){ onDispose { ... } } to track route enter/exit. The framework already runs lifecycle phases; the UI duplicates them by hand.BottomNavigationContainer overrides activateGraphForRoute just to keep a MutableStateFlow<String> selected in sync. Every container variant repeats this glue.Type<Map<String,Int>>() collapses to Type("Map"). Two semantically different ports become indistinguishable to autoWire.Reply<R> ask ceremonyActorNode.kt:109–125Reply<R> through the sealed message class. The call site is clean (ask { reply -> Msg.Query(reply) }); the schema isn't.get() = shell.xyz lines. There is no notion of a sub-graph contract or module re-export.Edge constructor mutates port state in initportgraph/edge/Edge.ktStateFlow and SharedFlow everywhere but the port layer has no notion of an observable port. Consumers re-derive Flow plumbing per use site.The refinements organize into five concentric layers. Each one is small and orthogonal; each can be adopted independently. Lower layers know nothing about higher ones.
These are the cells the higher layers are built from. Each one is independently testable and ~50–200 LOC.
Type to carry full KTypeToday Type stores a string and an optional KClass. Two ports of List<String> and List<Int> collide. Switch to kotlin.reflect.typeOf<T>() (multiplatform since 1.6) and use structural equality.
inline fun<reified T> Type() = create(T::class) // Type<List<String>>() ≡ Type<List<Int>>()
inline fun<reified T> Type() = Type(typeOf<T>()) data class Type(val kType: KType) { val raw: KClass<*> get() = kType.classifier as KClass<*> } // Type<List<String>>() ≠ Type<List<Int>>() ✓
Side effect: autoWire can now compute structural compatibility (e.g. wire List<Message> consumer to MutableList<Message> provider if you opt in to variance).
connect() out of the Edge constructorEdges should be immutable values. A separate Wiring object accumulates pending connections, validates the lot, then commits atomically — firing port-lifecycle events only after the commit succeeds.
class Wiring(private val graph: Graph) { private val pending = mutableListOf<Pair<ConsumerPort<*>, ProviderPort<*>>>() fun<T> add(c: ConsumerPort<T>, p: ProviderPort<T>): Wiring = apply { pending += c to p } fun commit(): Result<List<Edge>> { /* validate all, then apply */ } } // usage graph.wire { chatActor.operations to operationsInteractor chatActor.timeline to timelineInteractor }.commit().getOrThrow()
This unlocks dry-run validation, partial rollback, and structured error reporting — a prerequisite for the Layer-4 module-merging story.
Lifecycle hooks to suspendrunBlocking.Today onTransition is synchronous, which forces nodes that need async setup to schedule it on coroutineScope and lose ordering. Add a parallel suspend fun onTransitionAsync(prev, next) hook driven through graph.coroutineScope so the next transition waits.
interface LifecycleAware { fun onTransition(prev: Lifecycle, next: Lifecycle) {} // sync, optional suspend fun onTransitionAsync(prev: Lifecycle, next: Lifecycle) {} // async, optional }
Graph then awaits all onTransitionAsync calls before advancing to the next state.
Behavior type for actor receive logicwhen pattern.An ActorNode's logic is "given a message, do something, and possibly become a new behavior." Today this is implicit in the receive(msg) method, with sealed-class dispatch and mutable fields. Make it explicit:
sealed interface Behavior<M : Any> { object Same : Behavior<Nothing> object Stopped : Behavior<Nothing> class Receive<M : Any>(val fn: suspend ActorContext<M>.(M) -> Behavior<M>) : Behavior<M> class Setup<M : Any>(val fn: suspend ActorContext<M>.() -> Behavior<M>) : Behavior<M> } val chatActor = behavior<ChatMsg> { receive { msg -> when(msg) { is ChatMsg.Open -> { ops.load(msg.id); become(open(msg.id)) } is ChatMsg.Close -> Behavior.Stopped else -> Behavior.Same }} }
The existing ActorNode stays; behavior {} compiles to an ActorNode whose receive just dispatches to the current Behavior. Existing subclasses keep working untouched.
Today every port is a value provider. Consumers call into the value directly. This works for repositories and interactors, but everywhere else the team rebuilds Flow/Channel/Deferred plumbing on the call site. Five port shapes cover >95% of real usage.
| Shape | Underlying | Consumer reads | Use case |
|---|---|---|---|
| ValuePort<T> | Direct reference (current behavior) | port.impl |
Repositories, services, singletons. |
| StatePort<S> | StateFlow<S> |
port.value, port.flow |
Replaces hand-rolled StateFlow in interactors (D4). |
| StreamPort<E> | Flow<E> or SharedFlow<E> |
port.collect { } |
Event buses, telemetry feeds, presence ticks (D12). |
| RequestPort<In,Out> | suspend (In) -> Out with retry/timeout policy |
port(input) |
Service calls, single-shot RPCs; replaces ad-hoc handler classes. |
| MailboxPort<M> | ActorRef<M> |
port.send(msg), port.ask { } |
How sibling nodes talk to an actor; replaces by consumes<MyActor>() (D3). |
The by provides<T>()/by consumes<T>() delegates stay for the ValuePort case; new delegates layer in next to them.
class ChatSessionInteractor(graph: Graph) : BasicNode(graph) { // L1 — declarative state, single line val chatState by state<Data<Chat>>(Data.Loading) val messages by state<List<ChatMessage>>(emptyList()) val presence by state<Map<String, ChatPresence>>(emptyMap()) // L1 — declarative downstream event channel val typingEvents by stream<TypingEvent>() // L1 — declarative request endpoint val sendMessage by request<DraftMessage, SentMessage>(retry = backoff(3)) // L1 — declarative inbound mailbox; auto-wired by actor key val chatActor by mailbox(chatActorKey) }
by state / by stream delegates register provider ports automatically, indexed under the property name. No more provides<ChatSessionInteractor>(this) + exposePort chain — the node's surface is its property list, period.
With Layer-1 port shapes in place we can express two of the most useful organism patterns: React-style hook nodes and Akka-style behavior actors. Both compile down to existing concrete Node subclasses; both are entirely optional.
React's reducer pattern (useReducer + useEffect + useEvent) cleanly separates what state we have from what events change it and what side effects happen. Today every interactor reimplements this poorly. Wrap it once:
val chatController = graph.controller<ChatState, ChatEvent>( initial = ChatState.Loading, label = "chat", ) { // reducer: events ➜ next state reduce { state, event -> when(event) { is ChatEvent.Loaded -> ChatState.Ready(event.chat, event.messages) is ChatEvent.Typed -> (state as? ChatState.Ready)?.addMessage(event.msg) ?: state is ChatEvent.Disposed -> ChatState.Loading }} // effect: state transitions trigger IO effect(on = { it.chatId }) { state -> if (state is ChatState.Loading) sendMessage(DraftMessage(...)) // RequestPort call } // derived selector: re-derive only on dependency change val unreadCount = derived { state -> state.messages.count { !it.read } } // lifecycle: bound to node Attaching/Saving onEnter { chatActor.send(ChatMsg.Open(payload.chatId)) } onLeave { chatActor.send(ChatMsg.Close) } }
What we get:
MutableStateFlows. Fixes D4.useEffect deps, but built on StateFlow. Fixes D5.DisposableEffect. The UI layer becomes a thin renderer of state.collectAsState().The result is a ControllerNode<State> with a StatePort<State> already exposed — readable from Compose, from sibling nodes, and from tests, without any new glue.
Inspired by L0.4, this is the high-level surface for the same primitive:
val chatActor = graph.actor<ChatMsg>(key = chatActorKey) { setup { // preStart equivalent — runs before first message operations.connect() } receive<ChatMsg.Open> { msg -> chatState.update { ChatState.Loading } val chat = sendMessage(DraftMessage(...)) chatState.update { ChatState.Ready(chat) } } receive<ChatMsg.Close> { Behavior.Stopped } supervise { on<NetworkError> { ActorDirective.Restart } default { ActorDirective.Escalate } } teardown { operations.disconnect() } }
Three wins:
when-statement receive — better IDE jump, smaller diffs.onFailure. Fixes the lack of structured error handling.ask<Out>(timeout) { reply -> Msg.Query(reply) }), but request-shaped messages can be expressed as RequestPort instead. Sidesteps D8 for the common case.exposed { } — one place declares the node's public surfaceThe current exposePort() chain (D2) is the worst offender for ceremony. Replace with a node-local exposed { } block, evaluated once at attach time, that registers ports into the parent graph's DI scope:
Node(::ConfigRepository).apply { exposePort(configRepository) } Node(::StickerRepository).apply { exposePort(stickerRepository) } Node(::SessionHydrationInteractor).apply { exposePort(sessionHydrationInteractor) }
class ConfigRepository(g: Graph) : BasicNode(g) { val repo by state<Config>(Config.Default) exposed { +repo } // one line, declarative } // parent graph: Node(::ConfigRepository) Node(::StickerRepository) Node(::SessionHydrationInteractor)
A route table co-locates pattern, payload type, screen, and guards. RouteBinding subclasses disappear — their only job was to carry edge references, which the table holds globally.
val routes = routes { route<ChatPayload>("/chats/{id}") { screen = ::ChatScreen controller = ::ChatController guard { auth.user.value != null } onEnter { actor.send(ChatMsg.Open(payload.chatId)) } onLeave { actor.send(ChatMsg.Close) } navigatesTo("/profile/{userId}") navigatesTo("/groups/{groupId}") } route<FriendProfilePayload>("/profile/{userId}") { screen = ::FriendProfileScreen } deeplink("bestbuds://chat/{id}") to "/chats/{id}" } graph.install(routes)
The routes DSL is just data — it compiles down to RouteNode instances plus a registry. The runtime can answer questions previously impossible:
guard./chats/{id}, where can the user go?" → navigatesTo edges.Current back(result: R) erases R to Any. Make the return type part of the route declaration:
route<PickStickerPayload, PickedSticker>("/picker/sticker") { screen = ::StickerPickerScreen } // caller: val picked: PickedSticker = routeBinding.pushForResult(stickerPickerEdge, ...) // callee: routeBinding.back(PickedSticker(id)) // type-checked at compile time
Today every ContainerNode subclass mirrors the active child into its own MutableStateFlow. Push that into the base class:
abstract class ContainerNode(graph: Graph, ...) : Node(graph) { val selectedKey: StateFlow<String> // driven by activateGraphForRoute val activeGraph: StateFlow<Graph?> // derived fun select(key: String): Boolean // reverse: programmatic switch }
BottomNavigationContainer drops its 70-line glue and becomes:
class BottomNavigationContainer(g: Graph, p: String, c: Map<String, ChildGraph>) : ContainerNode(g, p, c.values.map { it.graph }) { val controller by provides<Controller>(Controller(selectedKey)) }
The engine module's WorkbenchGraph re-exports thirty properties from a child shell graph. The framework should know about this pattern. A module is a subgraph plus a public contract:
val messagingModule = module { provides<MessagingApi>() provides<PresenceTracker>() consumes<AuthSession>() build { graph -> ServiceNode(graph, MessagingServiceClient(Servers.MessagingServer)) Node(::PresenceTrackerNode) } } // inside a parent graph: use(messagingModule) // one line — wires consumes/provides automatically
Two things to note: (1) the contract is checked at commit time using the L0.2 wiring machinery — missing AuthSession → compile-time-quality error; (2) modules nest: a module can use(otherModule).
actorSupervisorStrategy is currently a flat per-graph function. Promote it to a tree:
Concretely: every ActorNode can declare its own supervise { } block (already in Layer 2.2). When a directive is Escalate, the failure walks up the graph parent chain until handled. This mirrors Erlang's supervisor hierarchies and gives operators a single tunable.
The current cross-graph forward works but is invisible: NavigationEdge.isCrossGraph is a derived boolean and the bubble-up is unwritten. Make cross-graph edges a first-class concept:
val crossEdge: CrossGraphEdge<ChatPayload> = chatGraph.routes.chatRoute crosses rootGraph.routes.chatRoute
This gives us a registry the workbench/devtools can render and the runtime can validate at startup. Today, a typo in a target graph silently fails at runtime with a Logger.w.
The visitor pattern is read-only. Add a MutatingVisitor that returns a GraphPatch instead of side-effecting. A devtools "rewire" command, a hot-reload swap, or a feature-flag-driven node swap all become structured operations that the wiring machinery (L0.2) can apply atomically.
ChatGraph Re-imaginedWhat does a real screen look like once all five layers exist? Here is BestBuds' ChatGraph (which today spans ~80 lines plus three separate RouteBinding classes plus a manually wired actor).
// BestBuds.kt:128-179 class ChatGraph(parent: Graph?) : Graph(parent, label = "Chat") { val chatRoute: RouteNode<ChatPayload, ChatBinding> val friendProfileRoute: RouteNode<FriendProfilePayload, FriendProfileBinding> val groupProfileRoute: RouteNode<GroupProfilePayload, GroupProfileBinding> init { Node(::MessageRepository) Node(::ChatSessionOperationsInteractor) Node(::ChatSessionStateInteractor) Node(::ChatTimelineInteractor) Node(::ChatAnalyticsInteractor) Node(::ChatSocketInteractor) Node(::ChatTypingInteractor) Node(::ChatActor) friendProfileRoute = Route("/profile/{userId}") { FriendProfileBinding() } groupProfileRoute = Route("/groups/{groupId}") { GroupProfileBinding() } chatRoute = Route("/chats/{id}") { ChatBinding( friendProfileEdge = it.edge(friendProfileRoute), groupProfileEdge = it.edge(groupProfileRoute), ) } Node(::ChatScreen) Node(::FriendProfileScreen) Node(::GroupProfileScreen) autoWire() addRoot(chatRoute, Payload()) } } // + ChatBinding (5 lines), FriendProfileBinding (3), GroupProfileBinding (3) // + ChatActor with 6 manual `by consumes<T>()` declarations // + ChatSessionStateInteractor with 6 manual MutableStateFlow fields
val chatModule = module(label = "Chat") { // 1. Sub-nodes are normal — Layer 2 conventions remove boilerplate inside each. +MessageRepository() +ChatSessionOperationsInteractor() +ChatSessionStateInteractor() // uses `by state` from L1 +ChatTimelineInteractor() +ChatAnalyticsInteractor() +ChatSocketInteractor() +ChatTypingInteractor() // 2. The actor is a behavior block — no subclass, no consumes ceremony. actor<ChatMsg>(chatActorKey) { setup { operations.connect() } receive<ChatMsg.Open> { msg -> state.update { Loading }; ops.load(msg.id) } receive<ChatMsg.Close> { Behavior.Stopped } supervise { on<NetworkError> { Restart } } } // 3. Routes are declarative; navigation targets are listed inline. routes { route<ChatPayload>("/chats/{id}") { screen = ::ChatScreen controller = ::ChatController onEnter { actor.send(ChatMsg.Open(payload.chatId)) } onLeave { actor.send(ChatMsg.Close) } navigatesTo("/profile/{userId}") navigatesTo("/groups/{groupId}") } route<FriendProfilePayload>("/profile/{userId}") { screen = ::FriendProfileScreen } route<GroupProfilePayload>("/groups/{groupId}") { screen = ::GroupProfileScreen } } } // somewhere up in App.kt use(chatModule)
What disappeared:
RouteBinding subclasses — gone. Route →route navigation is a declarative navigatesTo.ChatBinding(friendProfileEdge = ..., groupProfileEdge = ...) constructor — gone. The route table knows.by consumes<T>() declarations on ChatActor — gone. The actor is a behavior block scoped inside chatModule, so all sibling nodes are in scope.MutableStateFlow declarations on the state interactor — gone. by state<T>(init).LaunchedEffect/DisposableEffect in the Compose screen for open/close — gone. The route's onEnter/onLeave is the single source of truth.What is still possible at lower layers:
ContainerNode subclass with custom UI semantics? Still allowed — it just inherits the new selectedKey StateFlow for free.ProviderPort<T> with custom wiring policy? Still allowed — the by state/by stream delegates compile to the same primitive.ActorNode<M> subclass with custom receive? Still allowed — actor { receive<Open> { } } is just sugar.Every layer is additive: nothing in this plan deletes or replaces an existing API. The migration story is one of opt-in, screen by screen.
| Phase | Scope | Risk | Verification |
|---|---|---|---|
| P0 — L0 primitives | Strengthen Type with KType; add Wiring alongside connect(); add onTransitionAsync. |
Low — old paths untouched. | All existing tests pass; add specific KType-equality tests. |
| P1 — Port shapes | Add state/stream/request/mailbox delegates next to provides/consumes. |
Low — new properties, no replacement. | Convert one interactor (e.g. ChatSessionStateInteractor) and diff lines. |
P2 — exposed { } |
Add the block; legacy exposePort stays callable. |
Low | Convert BestBuds.kt:389-411; check that DI lookups still resolve. |
| P3 — Hook controllers | Introduce controller<S, E> { reduce; effect; ... } as a thin wrapper around ControllerNode. |
Medium — touches UI integration. | Migrate ChatScreen; visually verify navigation, state, lifecycle still work. |
| P4 — Behavior actors | Introduce actor<M>(key) { receive; supervise; ... }. |
Medium — new code path through ActorNode. |
Migrate ChatActor; ensure ask/send/supervision still pass ActorNodeIntegrationTest. |
| P5 — Route table | routes { route<P>(...) { } } registers RouteNodes under the covers. |
Medium | Migrate ChatGraph; verify cross-graph forward, deeplinks, guards. |
| P6 — Modules & supervision trees | Add module / use / hierarchical supervision. |
Highest — touches engine module's nested-graph delegation. | Migrate WorkbenchGraph; verify all 30 re-exposed services still resolve. |
| P7 — Deprecate | After two release cycles where new modules use the DSL, mark legacy RouteBinding subclass pattern and inline exposePort as deprecated. |
Low — soft-deprecation only. | Run the workbench inspector to detect remaining legacy patterns. |
ChatGraph is the canonical pilot — is the gate for the next phase. No phase ships without removing >100 lines from the BestBuds codebase.
@Screen, @Provides, etc. would force a compiler plugin into the multiplatform build for every consumer. Reified generics + property delegates cover the same surface in pure source.exposed { } block is sugar over Koin registration, not a replacement.Node hierarchy is small and load-bearing. Hooks/behavior actors are builders that emit existing concrete types.RouteNode(graph, pattern) { Binding } can keep writing it forever.StateFlows; it doesn't care whether the StateFlow came from a hand-rolled MutableStateFlow or a StatePort.KType matching go? Exact equality is safe but rigid; subtype matching (List<String> accepts ArrayList<String>) is intuitive but requires careful variance handling. Start with exact, add covariance behind an opt-in.
RequestPort subsume ServiceNode? The handlers in service/* already are request/response. We could deprecate the GetHandler/PostHandler hierarchy in favor of a single RequestPort with HTTP-method metadata. Worth a prototype.
JsExport? Reified-generic property delegates don't always round-trip through @JsExport. May need a parallel controllerJs(...) factory that avoids reified types at the JS boundary.
StatePort. ActorNode already has ObjectStore-backed state. Should by state<S>() support an persisted = true flag that automatically rehydrates from the store on Restoring?
reaktor-react fit? The plan is largely Compose-centric, but a sibling module exists for React. Hook controllers in particular need to translate cleanly to React hooks. Should the controller DSL emit both a Compose and a React adapter?
ChatGraph.