> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modaal.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating a CombineRIBs App to Duet

> How an existing Production app project gains a shared Kotlin core and an Android app, one feature at a time, without a flag-day rewrite and without pausing feature work.

A Modaal project created from the **Production app** card is a CombineRIBs app: Routers, Interactors, Builders and Components, iOS only. This page is the route from there to the shape the **iPhone and Android together** card scaffolds — a shared Kotlin core, a native Android app, and iOS shells consuming the same compiled logic.

The migration is **per feature, revertible, and never a flag day**. Duet-shaped features live beside CombineRIBs ones inside one app for the whole migration, the iOS app keeps building and shipping throughout, and you can stop between any two features and keep everything banked so far.

<Note>
  **Prerequisite.** The migration needs Android support enabled for your installation — the tools that graft the Kotlin core and the Android app are the same ones gated behind it. [Join the Android open beta](https://modaal.dev/android-open-beta) if the Multiplatform lane is missing from your new-project wizard. Building both platforms is part of [Pro](/credits-and-plans).
</Note>

## Does this apply to your project?

| Your project came from                               | This page                                                                                                                                                                          |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Production app** (`ios-ribs`)                      | Yes — this is the route.                                                                                                                                                           |
| **Watch + iPhone**, the iPhone half                  | Yes. The watch target is MV and is not touched.                                                                                                                                    |
| **2D game / Interactive app** (`ios-ribs-spritekit`) | Ask first. No Duet flavor scaffolds a SpriteKit scene host, so the render surface needs its own answer before step 0.                                                              |
| **Quick prototype** (`ios-mvvm`)                     | No. MVVM has no migration path — see [Starting a new project](/articles/new-project#choosing-a-template).                                                                          |
| Either **Duet** card                                 | Not needed. A Duet Swift-flavor project reaches Android through [its own convergence](/articles/duet#adding-android-to-an-iphone-only-project), which skips most of what is below. |

**How to tell what you have:** a CombineRIBs tree has `import CombineRIBs` in its sources and a `RIBs/` directory per feature, and it has **no** `parity/manifest.yaml` at the repository root. A Duet tree has that file and no CombineRIBs import.

## What you end up with

The finished tree is shape-equivalent to one a fresh **iPhone and Android together** scaffold emits: `src-kmp/` holding the Gradle plane, the core modules, one module per feature and the Android app; `src-ios/` shells consuming the assembled core; `parity/` holding the manifest, the feature specs and the fixtures; and `tools/duet`. No `import CombineRIBs` anywhere, and no migration package left.

[Duet](/articles/duet) describes that destination in full. This page is how an app already in flight gets there.

## Three properties that shape everything else

<CardGroup cols={3}>
  <Card title="Coexistence is first-class" icon="layer-group">
    Converted features and CombineRIBs features live side by side inside one app for the entire migration. There is no branch, no freeze, and no big-bang cutover.
  </Card>

  <Card title="Each conversion is revertible" icon="rotate-left">
    A feature converts end to end before the next one starts, and each conversion is revertible on its own: deleting the new module and restoring the old interactor body is a clean revert.
  </Card>

  <Card title="Every stop is a resting state" icon="pause">
    Halt after any feature and keep what you have banked — the tests, the determinism, one DI idiom, and a running Android app. The end state is not all-or-nothing.
  </Card>
</CardGroup>

## Before you start

Two questions worth answering out loud, because they change how much work follows each converted feature rather than which steps you take:

<Steps>
  <Step title="What does 'Android' mean for you?">
    A shared core you can build on, a rough build to try on a device, and a store-shipped Android product all walk the same steps. What differs is how much Compose shell and platform work follows each crossed feature.
  </Step>

  <Step title="Is a date attached?">
    A running Android app on an emulator arrives early — at step 2, before any feature is converted. A *shipped* Android product arrives feature by feature, and its calendar time scales with your feature count. Set that against the date before the audit rather than after it.
  </Step>
</Steps>

## The stages

<Steps>
  <Step title="Step 0 — record the decision, and stop digging">
    Before anything is scanned or generated, the repository's own agent documents are edited: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` and any always-applied mirrors of them (`.cursor/rules/*.mdc`, `.github/copilot-instructions.md`). The edit records the migration decision and one rule: **from today, every new feature is authored in the target shape — a reducer plus a thin shell — never as a new Router.**

    Those files instruct every future session in the repository. One left unedited keeps telling the next session to build a Router, which is why this edit comes before the audit rather than after it.
  </Step>

  <Step title="Step 1 — the readiness audit">
    A static scan of the app produces three artifacts inside your project:

    * **The readiness report** — a census of the distance to the target shape: routers, listener protocols, legacy workers, timing hits, vendor-SDK quarantine breaches, DI and view-binding idioms.
    * **The migration ledger** (`.modaal/duet-migration-ledger.json`) — one row per surface, each with a status: `legacy`, `in-progress`, `converted`, `retired`, `blocked` or `wont-convert`. Re-running the audit merges into the same file, so the progress figure is always current.
    * **Spec drafts** (`.modaal/duet-migration/spec-drafts/<feature>.md`) — a statically extracted head start on each feature spec.

    Treat the counts as pointers rather than verdicts: it is a text scan, and every extracted row is a candidate to verify against the source.
  </Step>

  <Step title="Step 2 — the graft">
    One call puts the whole Duet substrate into the existing tree: the Kotlin core plane under `src-kmp/` (Gradle wrapper, the shared-service, theming and umbrella modules, the per-feature module plane), the Android app shell, and the parity workspace (`parity/manifest.yaml`, `parity/fixtures/`, `parity/feature-specs/`, the pinned `tools/duet` wrapper). It also emits one synthetic `counter` feature with the scenario test its fixtures are recorded from.

    What it does **not** touch: your legacy app's sources and tests. It declares the two migration packages in the iOS app's `xcodegen.yml` without linking them, and appends a section to the agent documents and a block to `.gitignore`.

    **Exit gate:** the Android app builds and launches on an emulator with the synthetic feature passing from a recorded fixture, and the legacy iOS app builds and runs unchanged.

    <Warning>
      Never hand-build this. An Android target, a Gradle plane or a Kotlin source tree added by hand is missing the migration marker in `.modaal/project.json` that routes every later call, along with the manifest rows and module wiring.
    </Warning>
  </Step>

  <Step title="Step 2b — wire the core into the iOS app">
    Immediately after the graft's gate, a second call links the assembled Kotlin core into the iOS app's build: the consumer package, the assemble build pre-actions, the boundary test lane, and the assemble script.

    Nothing about the running app changes — it still runs its CombineRIBs code. What this buys is that every later feature flip is feature work with no build wiring left to add.

    **Exit gate:** the core assembles, the legacy app builds and runs unchanged with the framework linked, and the boundary replay passes.
  </Step>

  <Step title="Step 3 — the per-feature plan">
    Every feature gets a row in `parity/manifest.yaml` and an order in the ledger. Three rules set that order:

    * **Riskiest logic first** — the flows whose untested behavior worries you most: auth, save paths, navigation decisions. Conversion is how a feature *gains* exhaustive tests, so spend it where tests are most needed. It is also the same cost to convert either way.
    * **Leaves before the spine** — self-contained screens before the navigation tree that hosts them.
    * **Navigation staged last.**
  </Step>

  <Step title="Step 4 — features cross, one at a time">
    This is the bulk of the migration and it interleaves freely with normal product work. [The next section](#what-crossing-one-feature-looks-like) walks through one crossing.
  </Step>

  <Step title="Step 5 — the shell swap and the finish line">
    Once a feature's logic is in the Kotlin core, its remaining CombineRIBs shell is re-homed onto Duet types, and the migration package's dependency edge is deleted. [The finish line](#the-finish-line) lists what has to hold.
  </Step>
</Steps>

<Info>
  Steps 3 and 4 interleave with ordinary feature work. You are not asked to freeze the product for the duration — that is the point of converting one feature at a time.
</Info>

## What crossing one feature looks like

A feature's logic is authored **twice, in one order, inside one window**: Swift first, then Kotlin, and the Swift copy is deleted at the end of the crossing. The two halves have different oracles, which is why they are two changes rather than one.

### First half: the Swift twin, inside the legacy app

<Steps>
  <Step title="Finish the feature spec">
    One page, platform-agnostic: state fields, action cases, transitions, effect payloads and their seams, delegate events, fixtures. The extracted draft from step 1 is the starting point, verified row by row against the source. The legacy interactor is the behavior source; its existing Quick/Nimble spec is the acceptance oracle.

    Deliberate convergences get written down here rather than ported silently — behavior that varied by build config (`#if DEBUG` aliases, scheme shortcuts) does not survive into a reducer, and incidental platform-parser tolerances are converged to one chosen, fixture-pinned rule.
  </Step>

  <Step title="Extract the pure feature module">
    `State`, `Action`, the reducer, `EffectPayload`, and an `Environment` protocol. The reducer is pure: no platform imports, no vendor SDKs, no `Date()`, no `UUID()`. Time and identity arrive as action payloads or seeded state.
  </Step>

  <Step title="Port the tests and record the fixtures">
    The legacy spec is ported **1:1** onto the deterministic test store — every `it` becomes a scenario step sequence. Behavior differences found here are findings about the old code, not test bugs. Then the fixtures are recorded and committed.

    **The mutation drill runs here and is not optional:** break one reducer guard on purpose, confirm the fixture replay goes red, revert. A drill that stays green means the harness is not wired to anything.
  </Step>

  <Step title="Shrink the interactor to a shell">
    The Interactor keeps its class name and its RIBs lifecycle and loses its logic. A finished shell does exactly three things: binds presenter reports to store actions, projects state onto the existing view state, and forwards delegate events to the old listener. Builder, Router and View stay untouched, which is what keeps the conversion revertible.
  </Step>

  <Step title="Implement the environment and its fake">
    The `Environment` protocol is the feature's only door to the platform, implemented live by adapters over your existing services and for tests by one hand-rolled fake. A feature whose only environment duty is the delegate sink has no port, no worker and no fake — that is a valid shape, not an omission.
  </Step>
</Steps>

At the end of this half the feature still ships on iOS, exercised by your app's own test suite, and the extraction is proven to have preserved behavior.

### Second half: the Kotlin twin, and Android

The feature's manifest row now declares both a `swift:` and a `kotlin:` source. That dual state is **the window**, and it is where the parity lints run.

<Steps>
  <Step title="Write the Kotlin reducer against the recorded fixtures">
    The fixtures recorded from the Swift twin replay byte-identically from the Kotlin one. That is what "the port preserved behavior" means, and it is a different oracle from the one the first half used.
  </Step>

  <Step title="Mount the feature in the Android app">
    A thin Compose shell and the feature's platform workers, app-side. The feature is not crossed until the Android app runs it.
  </Step>

  <Step title="Retire the Swift twin">
    The manifest row flips to `kotlin:`-only and the Swift copy is deleted in the same change. The iOS app runs the feature from the assembled core — the build wiring for that landed back at step 2b.
  </Step>
</Steps>

<Tip>
  **The acceptance test is the same throughout: the fixtures do not change.** A behavior-preserving move shows metadata-only churn — scenario source locations, step labels — and zero state or effect bytes. Behavioral churn during a port is a finding to investigate, never a fixture to edit.
</Tip>

<Warning>
  **A window is a window, not a shape.** A Kotlin twin that stands permanently beside a Swift one costs a measured \~40% authoring premium and is consumed by nothing but its own gates. Each window opens and closes inside one feature's crossing.
</Warning>

## What happens to each piece of your code

Every CombineRIBs shape has exactly one fate. The Dependency / Component / Builder triple is the destination, not residue — Duet composition uses the same triple at every level.

| Legacy shape                    | Fate                                                                                                                                                                          |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<X>Dependency` protocol        | **Port unchanged.** It is already the Duet shape.                                                                                                                             |
| `<X>Component`                  | **Port.** The feature's environment factory becomes a Component member.                                                                                                       |
| `<X>Builder` / `<X>Buildable`   | **Port, minus resolution.** The mount job stays; a Duet Builder resolves nothing.                                                                                             |
| `<X>Router` / `<X>Routing`      | **Delete.** Its job became state-driven child mounting in the shell.                                                                                                          |
| `<X>Interactor` + `<X>Listener` | **Delete.** The logic left for the reducer; the three shell duties move to the view shell; delegate events are enum cases, so the listener protocol dies with the interactor. |
| CombineRIBs `Worker`            | **Already gone** by then — converted to Duet workers during the feature's crossing.                                                                                           |
| Views and view controllers      | **Untouched.**                                                                                                                                                                |

The measured shape of one finished shell swap: **four files changed in the app module, none in the feature package** — the interactor file renamed to a view shell (34 lines), the builder (28 lines), the renamed spec (18 lines), and one deleted `import CombineRIBs`. By the time a feature reaches the swap, every hard part is behind it.

## What it costs

Measured on a production app of 15 RIBs and roughly 17k lines of RIB-layer code, converted end to end — every feature, router retirement included — in **six calendar days** of agent-driven sessions with human review:

| Phase                                                 | Measured  |
| ----------------------------------------------------- | --------- |
| Foundation — gates, tooling, device pass              | ≈2 days   |
| First feature lane — 3 features, one of them 3.8k LOC | ≈1.5 days |
| Remaining tab features — 3                            | ≈1.5 days |
| Root and spine logic — 5 slices                       | 1 day     |
| Router retirement and state persistence               | ≈1.5 days |

Leaf features measured **under a day each**, and a 3.8k-LOC feature authored directly from its finished spec landed in one session — writing the spec first is the fast order, not the ceremonial one.

<Warning>
  **What this table does not price.** It was measured on a migration that converted every feature to Swift and reached the Kotlin core afterwards, so it prices the architecture work — the extraction, the tests and fixtures, the router retirement. It does **not** price the second half of each crossing: the Kotlin twin, its Compose shell, and its platform workers. It also does not price the graft, which is one call plus its exit gate.

  Scale it by your feature count and interactor sizes, then re-price from your own repository once two or three features have crossed.
</Warning>

## What you can check yourself

The migration installs its own gates at step 2, and they are ordinary commands in your repository:

| Command                     | What it answers                                                        |
| --------------------------- | ---------------------------------------------------------------------- |
| `tools/duet lint`           | Is the manifest well-formed? The fast red, run on every manifest edit. |
| `tools/duet record --check` | Did the fixtures change? The port oracle and the CI drift gate.        |
| `tools/duet verify`         | Do both platform lanes pass? The same gate CI runs.                    |
| `tools/duet doctor`         | Did anything take a concurrency shortcut the swap tempts?              |

Progress is readable without running anything: `.modaal/duet-migration-ledger.json` carries a status per surface, and re-running the audit refreshes it.

Three safety rails hold throughout:

* **The leak detector keeps its authority** while a feature still has its Router, and stays green through every conversion.
* **Visuals get snapshotted during shell swaps.** The fixtures pin behavior; snapshots pin pixels.
* **Skipped and flaky legacy tests go in a visible registry with an owner** — never deleted quietly, and never made to pass by widening a timing bound.

## The finish line

The migration is done when all of these hold:

* Zero `import CombineRIBs` anywhere in the tree.
* The migration package's dependency edge is deleted from every `Package.swift`.
* Every feature in `parity/manifest.yaml` is `kotlin:`-only — no `swift:` line outlives its window. `tools/duet verify` is green and `record --check` shows zero churn.
* The tree is shape-equivalent to a fresh **iPhone and Android together** scaffold.
* The legacy interactor specs are deleted with their interactors — their scenarios were ported 1:1 during conversion, so a surviving spec tests a deleted type.
* The repository's own documents are swept: any README or agent-instructions file still describing the RIBs shape is rewritten in the same change. A document describing a shape the repository no longer has is not history — it is instructions to the next reader.
* The app's own device pass covers the swapped flows.

## Four things that are never the answer

<AccordionGroup>
  <Accordion title="Changing the architecture and the language in one step" icon="ban">
    The unit is the feature, not the app. Inside a feature's window the Swift twin lands first with your app's own tests as its oracle, then the Kotlin twin with the fixtures as its oracle. Two changes, two gates. Collapsing them leaves you with no way to tell which half broke.
  </Accordion>

  <Accordion title="Hand-adding an Android target, Gradle build or Kotlin tree" icon="ban">
    The graft emits them as one atomic step along with the parity workspace and the migration marker that routes every later call. A hand-built plane is missing that marker, the manifest rows and the module wiring.
  </Accordion>

  <Accordion title="Editing a fixture to make a port pass" icon="ban">
    Fixtures are build products. Re-record and read the diff instead: behavioral churn during a port is a finding about the port, and editing the recording deletes the evidence.
  </Accordion>

  <Accordion title="Keeping the migration package after the last legacy symbol is gone" icon="ban">
    One dependency edge in at the graft, one dependency edge out at the finish line. It exists so the frozen CombineRIBs fork never enters the Duet framework's own dependency graph, and it has no job once nothing imports CombineRIBs.
  </Accordion>
</AccordionGroup>

## Common questions

<AccordionGroup>
  <Accordion title="Do I have to stop shipping during the migration?" icon="ship">
    No. The iOS app builds and runs unchanged from the graft onward, converted and unconverted features coexist, and steps 3 and 4 interleave with ordinary product work. The one thing that changes on day one is that new features are authored in the target shape rather than as new Routers.
  </Accordion>

  <Accordion title="When does the Android app first run?" icon="mobile">
    At step 2, before any of your features are converted — the graft's exit gate is the Android app launching on an emulator with a synthetic feature passing from a recorded fixture. Your features then appear in it one at a time as they cross.
  </Accordion>

  <Accordion title="Can I stop half-way?" icon="pause">
    Yes, and it is a supported state rather than a broken one. You keep the recorded fixtures and the tests they came from, the determinism work, one DI idiom, and a running Android app carrying the features that crossed. The ledger records exactly where you stopped.
  </Accordion>

  <Accordion title="What if a feature can't be converted?" icon="triangle-exclamation">
    It gets a `blocked` or `wont-convert` status in the ledger with a note saying why, and the migration continues around it. Common blockers are found by the audit before they cost anything: a vendor SDK imported directly in an interactor, or timing done with raw `Timer` and `DispatchQueue.asyncAfter` in feature logic. Both are fixed by adding the seam first.
  </Accordion>

  <Accordion title="Why is the logic written in Swift first and then Kotlin?" icon="arrow-right">
    Because the two halves have different oracles. The Swift twin lives inside your app and is checked against your existing test suite, which proves the extraction preserved behavior while the feature still ships. The Kotlin twin is checked against the fixtures that extraction produced, which proves the port preserved it. One step, one oracle.
  </Accordion>

  <Accordion title="What do I actually type to start?" icon="comment">
    Tell the agent your project is going to Android, or that you want to migrate it to Duet. It reads the migration guide, makes the step 0 edits, runs the readiness audit, and comes back with the report and a proposed order before anything is generated. You approve the plan; it does not graft anything first.
  </Accordion>
</AccordionGroup>

## Read next

<CardGroup cols={2}>
  <Card title="Duet overview" icon="mobile-screen-button" href="/articles/duet">
    The destination: what Duet is, what the finished tree holds, and how features are authored once you are on it.
  </Card>

  <Card title="Handling platform-specific UI" icon="layer-group" href="/articles/cross-platform-ui-parity">
    Where the line between shared logic and native UI falls, and what enforces it — the part your Compose shells are written against.
  </Card>

  <Card title="Starting a new project" icon="block-brick" href="/articles/new-project">
    Every template Modaal offers, what the Legacy chip means, and which card a new project should start from.
  </Card>

  <Card title="Plans & pricing" icon="credit-card" href="/credits-and-plans">
    Building iOS and Android together is part of Pro.
  </Card>
</CardGroup>
