Skip to main content
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.
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 if the Multiplatform lane is missing from your new-project wizard. Building both platforms is part of Pro.

Does this apply to your project?

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 describes that destination in full. This page is how an app already in flight gets there.

Three properties that shape everything else

Coexistence is first-class

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.

Each conversion is revertible

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.

Every stop is a resting state

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.

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:
1

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.
2

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.

The stages

1

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.
2

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.
3

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.
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.
4

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.
5

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.
6

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 walks through one crossing.
7

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 lists what has to hold.
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.

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

1

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.
2

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.
3

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.
4

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.
5

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.
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.
1

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.
2

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.
3

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.
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.
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.

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. 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: 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.
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.

What you can check yourself

The migration installs its own gates at step 2, and they are ordinary commands in your repository: 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

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.
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.
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.
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.

Common questions

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.
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.
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.
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.
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.
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.

Duet overview

The destination: what Duet is, what the finished tree holds, and how features are authored once you are on it.

Handling platform-specific UI

Where the line between shared logic and native UI falls, and what enforces it — the part your Compose shells are written against.

Starting a new project

Every template Modaal offers, what the Legacy chip means, and which card a new project should start from.

Plans & pricing

Building iOS and Android together is part of Pro.