Skip to content

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"]
navigator.goTo(DetailScreen("42"))
navigator.pop()
navigator.resetRoot(LoginScreen)

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 Parcelable for 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