Reaktor Graph Refinement Plan
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.
Refinement scope. 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. The target is a runtime where the 80% case is one line and the 20% case is still wide open.
| Metric | Value |
|---|---|
| Kotlin files in reaktor-graph | 65 |
| Concrete node types today | 6 |
| Ceremony : business logic | ~3:1 |
| Proposed primitives | 12 |
Table of Contents
- Principles
- Snapshot — What Already Works
- Diagnosis — Where Expressiveness Leaks
- Mental Model — Five Layers
- Layer 0 — Atomic Primitives
- Layer 1 — Port Shapes
- Layer 2 — Reactive Nodes (Hooks & Behaviors)
- Layer 3 — Routing & Container Idioms
- Layer 4 — Module Composition & Supervision
- End-to-end Walkthrough — ChatGraph Re-imagined
- Migration — Additive Path
- Non-Goals
- Open Questions
✜ 1. Principles
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.
What this rules out. Annotation processors that own the entire feature ("@Feature" macros), reflection-based dependency injection at runtime, and any DSL that hides the underlying Graph/Node/Port identities. The DSL is sugar on top of types available for direct use.
✓ 2. Snapshot — What Already Works
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.
✗ 3. Diagnosis — Where Expressiveness Leaks
Findings from a sweep of bestbuds/modules/app and bestbuds/modules/engine, cross-referenced with the framework source.
-
D1 —
RouteBindingsubclasses are pure plumbingChatScreen.kt:77, BestBuds.kt:139Every navigable screen declares a
RouteBindingsubtype solely to hold cross-routeNavigationEdgereferences in constructor parameters. The class never carries logic. -
D2 —
exposePortchains repeat 3–10× per graphBestBuds.kt:389–411, WorkbenchGraph.kt:163–167Every node that should be reachable from sibling graphs needs
.apply { exposePort(myPort) }. The intent ("I am exposed asT") is declarative; the call is imperative. -
D3 —
by consumes<T>()bloat on actorsChatActor.kt:32–55Heavy actors declare 6–10 individual
consumesdelegates plus getters.autoWire()would handle most of these, but the developer still has to spell them out because the actor needs typed references. -
D4 —
MutableStateFlowsprawl in controllers/interactorsChatSessionStateInteractor.kt:27–46A single interactor declares six independent
StateFlows with manualfield = MutableStateFlow(...). There is no reducer composition, no useState-equivalent, no derived selectors. -
D5 — Lifecycle ↔ effect adhesion at the UIChatScreen.kt:44–54
Every screen pairs
LaunchedEffect(payloadKey)withDisposableEffect(Unit){ onDispose { ... } }to track route enter/exit. The framework already runs lifecycle phases; the UI duplicates them by hand. -
D6 — Container selection state hand-mirroredBestBudsContainers.kt:66–135
BottomNavigationContaineroverridesactivateGraphForRoutejust to keep aMutableStateFlow<String> selectedin sync. Every container variant repeats this glue. -
D7 — Generic type information lost at the port boundaryportgraph/port/Type.kt, registerProvider
Type<Map<String,Int>>()collapses toType("Map"). Two semantically different ports become indistinguishable toautoWire. -
D8 —
Reply<R>ask ceremonyActorNode.kt:109–125Every actor message that supports request/response must thread a
Reply<R>through the sealed message class. The call site is clean (ask { reply -> Msg.Query(reply) }); the schema isn't. -
D9 — Routes pushed imperatively, no route table or guardsRouteNode.kt, Graph.addRoot
There is no declarative inventory of "what routes does this graph have, and what protects them?" Deeplinks and auth gates have to be re-derived per call site.
-
D10 — Property delegation as a substitute for module compositionWorkbenchGraph.kt:127–145
Engine code re-exposes child-graph services via thirty hand-written
get() = shell.xyzlines. There is no notion of a sub-graph contract or module re-export. -
D11 —
Edgeconstructor mutates port state ininitportgraph/edge/Edge.ktNo transactional rewiring. A listener that throws leaves the graph half-connected. Connect failures cannot be batched/rolled back.
-
D12 — No stream-shaped port; everything is a method callport/ProviderPort.kt
Reaktor uses
StateFlowandSharedFloweverywhere but the port layer has no notion of an observable port. Consumers re-derive Flow plumbing per use site.
⧉ 4. Mental Model — Five Layers
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.
┌───────────────────────────────────────────────────────────────────────┐
│ L4 System graphs ↔ graphs, supervision, deep links │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ L3 Templates route tables, containers, modules │ │
│ │ ┌───────────────────────────────────────────────────────────┐ │ │
│ │ │ L2 Organisms hook-style nodes, behavior actors, │ │ │
│ │ │ reducer controllers │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ L1 Port Shapes StatePort, StreamPort, │ │ │ │
│ │ │ │ RequestPort, EventPort, │ │ │ │
│ │ │ │ EffectPort │ │ │ │
│ │ │ │ ┌───────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ L0 Atoms Type, Key, Port, Message, │ │ │ │ │
│ │ │ │ │ Behavior, Lifecycle │ │ │ │ │
│ │ │ │ └───────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
How to read this. A screen author lives at L2-L3. A subsystem author lives at L1-L2. A framework author lives at L0-L1. The point is that none of them should have to step outside their layer for the common case — but all paths down are open.
⦿ 5. Layer 0 — Atomic Primitives
These are the cells the higher layers are built from. Each one is independently testable and ~50–200 LOC.
L0.1
Strengthen Type to carry full KType
Fix D7. Generic-aware type identity at the port boundary.
Today 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.
Before
inline fun<reified T> Type() = create(T::class)
// Type<List<String>>() ≡ Type<List<Int>>()
After
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).
L0.2
Lift connect() out of the Edge constructor
Fix D11. Transactional, two-phase wiring.
Edges 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.
L0.3
Promote Lifecycle hooks to suspend
Fix D5. Async setup/teardown without leaking runBlocking.
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.
L0.4
Introduce a Behavior type for actor receive logic
Inspiration: Akka Behaviors (Typed). Replaces the unbounded sealed-class × when 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.
◇ 6. Layer 1 — Port Shapes
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). |
Port-shape declaration DSL
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)
}
Why this matters for D2 and D4. The interactor above is its own provider and its own consumer. The 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.
⚛ 7. Layer 2 — Reactive Nodes (Hooks & Behaviors)
With Layer-1 port shapes in place the model 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.
7.1 Hook nodes — React-flavored controllers
React's reducer pattern (useReducer + useEffect + useEvent) cleanly separates the current state 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:
- One reducer instead of six independent
MutableStateFlows. Fixes D4. - Effects scoped to dependency keys — React's
useEffectdeps, but built onStateFlow. Fixes D5. - onEnter/onLeave bound to the node's lifecycle, not the screen's
DisposableEffect. The UI layer becomes a thin renderer ofstate.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.
7.2 Behavior actors — Akka Typed-flavored mailboxes
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:
- Per-message handlers instead of one
when-statementreceive— better IDE jump, smaller diffs. - Supervise block declares directives by exception type instead of overriding
onFailure. Fixes the lack of structured error handling. - The ask pattern stays the same at the call site (
ask<Out>(timeout) { reply -> Msg.Query(reply) }), but request-shaped messages can be expressed asRequestPortinstead. Sidesteps D8 for the common case.
7.3 exposed { } — one place declares the node's public surface
The 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:
Before — BestBuds.kt:404-411
Node(::ConfigRepository).apply { exposePort(configRepository) }
Node(::StickerRepository).apply { exposePort(stickerRepository) }
Node(::SessionHydrationInteractor).apply {
exposePort(sessionHydrationInteractor)
}
After
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)
↪ 8. Layer 3 — Routing & Container Idioms
8.1 Declarative route tables (D1, D9)
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:
- "What deeplinks does this app support?" → iterate registry.
- "What routes are protected by auth?" → filter on
guard. - "From
/chats/{id}, where can the user go?" →navigatesToedges. - "Generate a debug nav graph picture" → render registry as graphviz.
8.2 Typed result returns (refines D8 for navigation)
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
8.3 Container with reactive selection (D6)
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))
}
8.4 Modules — reusable subgraph contracts (D10)
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).
⚘ 9. Layer 4 — Module Composition & Supervision
9.1 Supervision trees rooted in the graph hierarchy
actorSupervisorStrategy is currently a flat per-graph function. Promote it to a tree:
RootGraph
├── supervises: AlwaysRestart
│ ├── ChatGraph
│ │ ├── chatActor (mailbox)
│ │ │ ├── on NetworkError → Restart (max 3 in 1m → Escalate)
│ │ │ └── on default → Escalate
│ │ └── messagingActor
│ │ └── on default → inherit ChatGraph strategy
│ └── ProfileGraph
│ └── on default → Resume
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.
9.2 Cross-graph mesh — explicit named edges
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.
9.3 Visitor with mutation — framework-level transforms
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.
✍ 10. End-to-end Walkthrough — ChatGraph Re-imagined
What 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).
Today — verbatim shape (collapsed for space)
// 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
After refinement — same shape, ~40% fewer lines, more readable
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:
- Three
RouteBindingsubclasses — gone. Route →route navigation is a declarativenavigatesTo. - The hand-wired
ChatBinding(friendProfileEdge = ..., groupProfileEdge = ...)constructor — gone. The route table knows. - Six
by consumes<T>()declarations onChatActor— gone. The actor is abehaviorblock scoped insidechatModule, so all sibling nodes are in scope. - Six
MutableStateFlowdeclarations on the state interactor — gone.by state<T>(init). LaunchedEffect/DisposableEffectin the Compose screen for open/close — gone. The route'sonEnter/onLeaveis the single source of truth.
What is still possible at lower layers:
- A custom
ContainerNodesubclass with custom UI semantics? Still allowed — it just inherits the newselectedKeyStateFlow for free. - A raw
ProviderPort<T>with custom wiring policy? Still allowed — theby state/by streamdelegates compile to the same primitive. - A bespoke
ActorNode<M>subclass with customreceive? Still allowed —actor { receive<Open> { } }is just sugar.
↻ 11. Migration — Additive Path
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. |
Verification doctrine. Each phase ships behind a "old path still works" guarantee. A single screen migrated end-to-end — ChatGraph is the canonical pilot — is the gate for the next phase. No phase ships without removing >100 lines from the BestBuds codebase.
⊘ 12. Non-Goals
No annotation processors. @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.
No runtime reflection-based DI. Koin already does the DI; we don't add a second mechanism. The new exposed { } block is sugar over Koin registration, not a replacement.
No new top-level node type. The sealed Node hierarchy is small and load-bearing. Hooks/behavior actors are builders that emit existing concrete types.
No removal of imperative APIs. Every imperative API stays callable. The DSL is opt-in. A developer who prefers RouteNode(graph, pattern) { Binding } can keep writing it forever.
No replacement of Compose integration. The Compose layer reads StateFlows; it doesn't care whether the StateFlow came from a hand-rolled MutableStateFlow or a StatePort.
? 13. Open Questions
- How far does structural
KTypematching go? Exact equality is safe but rigid; subtype matching (List<String>acceptsArrayList<String>) is intuitive but requires careful variance handling. Start with exact, add covariance behind an opt-in. - Does
RequestPortsubsumeServiceNode? The handlers inservice/*already are request/response. We could deprecate theGetHandler/PostHandlerhierarchy in favor of a singleRequestPortwith HTTP-method metadata. Worth a prototype. - How do hook controllers interact with
JsExport? Reified-generic property delegates don't always round-trip through@JsExport. May need a parallelcontrollerJs(...)factory that avoids reified types at the JS boundary. - Persistence model for
StatePort.ActorNodealready hasObjectStore-backed state. Shouldby state<S>()support anpersisted = trueflag that automatically rehydrates from the store onRestoring? - Cross-graph mesh: validation timing. Layer-4 cross-graph edges are validated at startup. But subgraphs can be added/removed at runtime (tab creation). The wiring needs to support incremental validation without re-walking the whole world.
- Where does
reaktor-reactfit? 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?
Refinement plan v1 — targets reaktor-graph + reaktor-graph-port. Status: Proposal — awaiting pilot on ChatGraph.
Related docs
- Graph Kernel - Use to keep the core language small while modules add vocabulary.
- Graph Migration - Use when evolving reaktor-graph without breaking product apps.
- Actor Model - Use before adding actor behavior or moving chat/session workflows into actors.
- Service Layer - Use before adding service APIs, splitting reaktor-service out of reaktor-graph, wiring handlers to Spring/Workers, or making modules graph-projectable without hard graph dependencies.
- Work - Use before changing background scheduling, worker contracts, WorkRequest/WorkConstraints, BestBuds workers, edge/web scheduling, work telemetry, or graph projection for work.
- Graph Visitors - Use before designing a new graph pass or selector.