Why Orbit¶
Orbit is a Compose-driven architecture for Kotlin Multiplatform. This page explains what problem it solves, how the pieces fit together, and when it is — and is not — the right choice.
The problem¶
A typical Compose screen splits its logic across two very different worlds:
flowchart LR
subgraph VM["ViewModel world"]
A["Kotlin class"]
B["viewModelScope"]
C["StateFlow"]
D["SavedStateHandle"]
end
subgraph UI["Compose world"]
E["@Composable"]
F["remember"]
G["LaunchedEffect"]
H["rememberSaveable"]
end
VM -->|collectAsState| UI
UI -->|method calls| VM
Two lifecycles, two state systems, two mental models, and a bridge between them you maintain by hand. That split causes a series of familiar frictions:
| Friction | What it looks like in practice |
|---|---|
| Testing needs a device | Presentation logic reaches Android APIs, so tests need Robolectric or an emulator |
| Logic is not portable | ViewModel, SavedStateHandle and Parcelable tie you to Android |
| Navigation is stringly typed | Route templates, argument encoding, runtime failures for typos |
| Results are awkward | Passing a value back from a screen means a shared handle or a saved-state hack |
| Dialogs need state machines | showDialog booleans, onConfirm, onDismiss, and resetting flags afterwards |
| Two kinds of "surviving" | rememberSaveable for process death, a ViewModel for rotation — different tools, different rules |
The model¶
Orbit removes the split. A presenter is a Composable that returns state.
flowchart LR
S["Screen<br/>(routing key)"] --> REG{"Orbit registry"}
REG -->|resolves| P["Presenter<br/>@Composable present()"]
REG -->|resolves| U["Ui<br/>@Composable Content()"]
P -->|returns State| U
U -->|"state.eventSink(Event)"| P
Three types, one direction of flow:
Screen— an immutable key identifying what to show.DetailScreen("42").Presenter—@Composable fun present(): UiState. Runs logic, returns state.Ui—@Composable fun Content(state, modifier). Draws state, emits events.
The UI never holds a reference to the presenter. They communicate only through the state object
and its eventSink, which is what keeps them independently testable and independently
replaceable.
data class CounterState(val count: Int, val eventSink: (CounterEvent) -> Unit) : OrbitUiState
class CounterPresenter(private val navigator: Navigator) : Presenter<CounterState> {
@Composable
override fun present(): CounterState {
var count by rememberRetained { mutableIntStateOf(0) }
return CounterState(count) { event ->
when (event) {
CounterEvent.Increment -> count++
CounterEvent.Close -> navigator.pop()
}
}
}
}
Because present() is a Composable, everything you already know works inside it: remember,
LaunchedEffect, produceState, collectAsState. There is no viewModelScope because the
composition's lifetime is the scope.
Core features¶
1. Presenters are ordinary Kotlin¶
orbit-runtime depends only on compose-runtime — no compose-ui, no Android. A module full
of presenters compiles for JVM, iOS and Android identically.
flowchart TB
RT["orbit-runtime<br/>compose-runtime only"]
FD["orbit-foundation<br/>+ compose-ui, foundation, animation"]
OV["orbit-overlay"]
TS["orbit-test"]
FD --> RT
OV --> RT
TS --> RT
TS --> OV
F1["your :feature modules<br/>(presenters only)"] --> RT
A1["your :app module"] --> FD
A1 --> OV
Benefit: feature modules that hold logic never pull in UI toolkit dependencies, which keeps compile times down and makes the logic genuinely portable.
2. Navigation is a stack of objects¶
No graph declaration, no route strings, no argument encoding.
flowchart LR
subgraph BS["BackStack (top first)"]
R1["Record key=a7f2<br/>DetailScreen(42)"]
R2["Record key=91bc<br/>HomeScreen"]
end
NAV["Navigator"] -->|"goTo(screen)"| BS
NAV -->|"pop(result)"| BS
BS --> NOC["NavigableOrbitContent<br/>renders top record"]
Every push creates a record with a fresh unique key — not a hash of the screen. Push the same screen twice and you get two entries with completely independent state.
Benefit: a typo is a compile error, arguments are typed, and refactoring a screen's parameters is a normal rename.
3. State that survives the right things¶
Three tiers, each with a clear job:
flowchart TB
A["recomposition"] --> B["configuration change<br/>(rotation)"]
B --> C["process death"]
R1["remember<br/>survives A"]
R2["rememberRetained<br/>survives A and B"]
R3["rememberSaveable<br/>survives A, B and C"]
| API | Recomposition | Rotation | Process death | Holds |
|---|---|---|---|---|
remember |
yes | no | no | anything |
rememberRetained |
yes | yes | no | anything |
rememberSaveable |
yes | yes | yes | serializable only |
rememberRetained fills the gap that normally forces you to write a ViewModel. It holds
arbitrary objects — repositories, coroutine scopes, caches — with no Saver and no size limit.
Scoping is automatic and hierarchical:
flowchart TB
ROOT["Root registry<br/>(ViewModel-backed, survives rotation)"]
REC1["Record a7f2 registry"]
REC2["Record 91bc registry"]
V1["rememberRetained values"]
V2["rememberRetained values"]
ROOT --> REC1
ROOT --> REC2
REC1 --> V1
REC2 --> V2
When a record is popped, its whole subtree is discarded. When the device rotates, everything is kept. You do not manage any of it.
4. Overlays that return a value¶
A dialog becomes a suspending call.
sequenceDiagram
participant P as Presenter
participant H as OverlayHost
participant D as ConfirmOverlay
participant U as User
P->>H: show(ConfirmOverlay)
Note over P: suspends
H->>D: Content(navigator)
D->>U: renders dialog
U->>D: taps Delete
D->>H: navigator.finish(true)
H-->>P: returns true
P->>P: navigator.pop(DeletedResult)
val confirmed = overlayHost.show(ConfirmOverlay("Delete ${screen.id}?"))
if (confirmed) navigator.pop(DeletedResult(screen.id))
Benefit: no showDialog boolean, no callback pair, no state to reset. Cancelling the
calling coroutine dismisses the overlay automatically, so navigating away mid-decision cleans
up by itself.
5. Results flow back up the stack¶
sequenceDiagram
participant H as HomePresenter
participant B as BackStack
participant D as DetailPresenter
H->>B: goTo(DetailScreen) with resultKey
B->>D: DetailScreen composed
D->>B: pop(DeletedResult("42"))
B->>H: delivers result to answering record
H->>H: lastDeleted = "42"
val navigator = rememberAnsweringNavigator(backStack) { result ->
if (result is DeletedResult) lastDeleted = result.id
}
6. Persistence without Parcelable¶
Screen is a plain commonMain interface. Back stack persistence runs through
kotlinx-serialization on every platform.
flowchart LR
BS["BackStack"] -->|"rememberSaveable"| J["JSON via<br/>kotlinx-serialization"]
J --> OS["platform saved state"]
OS -->|"process restored"| J2["JSON"]
J2 --> BS2["BackStack rebuilt<br/>same record keys"]
No @Parcelize, no expect/actual annotation shim, no Android-only supertype in shared code.
The trade is explicit: every screen must be registered for polymorphic serialization, and a
missing registration fails at runtime rather than compile time.
7. Tests are plain unit tests¶
@Test
fun incrementing() = runTest {
CounterPresenter(FakeNavigator(CounterScreen)).test {
awaitItem().eventSink(CounterEvent.Increment)
assertEquals(1, awaitItem().count)
cancelAndIgnoreRemainingEvents()
}
}
flowchart LR
T["test"] -->|"present()"| M["Molecule<br/>runs the composition"]
M -->|"state stream"| TU["Turbine"]
TU --> A["awaitItem()"]
A -->|"eventSink(event)"| M
No emulator, no Robolectric, no Compose UI test rule. The same tests run on JVM and on iOS.
UIs are equally simple to test: they are functions of a state object, so every case — loading, error, empty — is reachable by constructing that state directly, without driving a presenter into the condition first.
Benefits at a glance¶
| Benefit | Why it follows |
|---|---|
| One mental model | Presenters and UIs are both Composables with the same lifecycle |
| Portable logic | orbit-runtime has no Android or compose-ui dependency |
| Fast tests | Presentation logic is a function; tests run on the JVM in milliseconds |
| Type-safe navigation | Screens are objects, not strings |
| Less boilerplate | No ViewModel, no SavedStateHandle plumbing, no dialog flags |
| Predictable state scope | Per-record registries torn down on pop, retained on rotation |
| Explicit wiring | Factories are hand-registered; nothing is generated or hidden |
Use cases¶
Kotlin Multiplatform apps. The strongest fit. Presentation logic lives in commonMain and
runs unchanged on Android, iOS and desktop, with only the UI layer varying — or shared too, via
Compose Multiplatform.
Apps with rich navigation. Many screens, screens returning values, tab-scoped back stacks
via resetRoot(saveState, restoreState), and the same screen open more than once.
Codebases where testing matters. If presentation logic is currently untested because tests need an emulator, moving it into presenters makes it JVM-testable with no infrastructure.
Modularised codebases. Feature modules depend on orbit-runtime only. They contribute
presenters and screens without depending on the app module or on each other.
Adopting incrementally. OrbitContent renders a single screen anywhere in an existing
Compose tree, so one screen can move to Orbit without touching the rest of the app.
When not to use it¶
Being honest about the boundaries:
- Single-screen apps. The registry and back stack are overhead you will not benefit from.
- Teams committed to Jetpack Navigation and ViewModel. Those are well-supported and widely understood; Orbit is an all-in architectural commitment, not an add-on.
- You want a large ecosystem. Jetpack has vastly more documentation, samples and community answers.
- Screens must be
Parcelablefor other reasons. Orbit deliberately does not use it.
How it fits together¶
The complete picture for a running app:
flowchart TB
ACT["Activity / iOS controller / desktop window"]
PO["ProvideOrbit<br/>registry + retained root"]
CWO["ContentWithOverlays"]
SET["SharedElementTransitionLayout"]
NOC["NavigableOrbitContent"]
DEC["NavDecoration<br/>transition between records"]
SSP["SaveableStateProvider<br/>per record"]
RSP["RetainedStateProvider<br/>per record"]
OC["OrbitContent<br/>resolve + render"]
PU["Presenter and Ui"]
ACT --> PO --> CWO --> SET --> NOC --> DEC --> SSP --> RSP --> OC --> PU
Each layer has one job, and each is optional except ProvideOrbit and the content host —
overlays, shared elements and even navigation can be left out if you do not need them.
Next steps¶
- Quick start — a running two-screen app.
- Screens, presenters and UIs — the core loop in depth.
- Navigation — back stack, results, persistence.
- Retained state — surviving rotation without a ViewModel.
- Testing — the presenter harness.