Skip to main content
The reliable way to make an Android app testable is to keep every decision in code that takes no environment: a pure function from state and action to new state, with clocks, IDs, network and permissions pushed behind effect values a worker performs later. Duet, Modaal’s cross-platform parity framework for native iOS and Android apps, fixes that architecture at project creation instead of leaving it as a per-team discipline — and then records each feature’s behavior as fixture files that CI replays on every push. This page states what stays standard Android, what the scaffold fixes in place, what that costs, and the measured results from a shipped app.

What stays standard Android?

The toolchain is unchanged: Kotlin, Jetpack Compose, Gradle, and a tree Android Studio opens and runs — nothing proprietary in it, no code generator you cannot leave. The architecture is the one Google’s own app architecture guide recommends in general form — unidirectional data flow, state as data, a clear UI/logic boundary. What Duet changes is who enforces it: the shape is scaffolded and gated rather than described in a document, and the logic layer compiles to commonMain so the iPhone app in the same repository runs the identical code. Inside a Duet Android app walks the resulting tree file by file.

What does a blank project leave open that Duet fixes?

A new Android project fixes almost nothing about structure: each screen’s author chooses where logic sits, how state is held, and what a test can reach. Duet replaces those per-screen choices with three project-wide ones:
  • One feature shape. Every feature is a serializable state, a sealed set of actions, and one pure reducer — no hand-rolled ViewModel variants, no per-screen state idiom.
  • Effects as data. A reducer never calls a repository, reads a clock, or generates an ID. It returns effect values; workers perform them and report back as actions. Anything nondeterministic round-trips through that seam, which is what makes behavior replayable.
  • Recordings as the test contract. Each feature carries a scenario — a Given/When/Then script that is compiled into fixture files. The fixtures are build products checked into the repository, and CI replays them byte-for-byte on every platform that implements the feature.

The same behavior, hand-rolled and fixed

A typical hand-rolled ViewModel, and the failure modes a test has to fight:
FavoritesViewModel.kt (a blank-project idiom)
Testing this class needs a fake repository, a test dispatcher, and control over a clock the code reads inline; the coroutine races the assertion, and the error branch fires only when the fake throws on cue. The same behavior as a Duet feature:
FavoritesFeature.kt (commonMain)
The timestamp is gone from the logic: if the saved-at time matters, the worker supplies it when it reports SaveSucceeded. And the test is no longer a class with fakes — it is a script over the reducer, recorded once and replayed forever:
FavoritesScenarioTest.kt
Recording this scenario produces a fixture file; the JVM test lane replays it on every push, and when the iPhone app implements the same feature, the identical bytes gate that side too.

How do the two setups compare?

What does it cost?

The fix has three costs; the Duet overview carries the full list:
  • The architecture is fixed at creation. A Duet project stays a Duet project; the conventions — the feature shape, the effect seam, the recording step — are not per-feature choices.
  • Views are still written per platform. The testability applies to logic. Compose screens are ordinary Compose, tested the way the platform tests UI, and an Android-plus-iOS change was measured at roughly 40% more agent effort than the single-platform equivalent in Modaal’s own runs.
  • The recording step is part of the loop. Changing behavior means updating the scenario and re-recording, not just editing code until the app looks right. That is the point — but it is a step a blank project does not have.

What happens when Android kills your app’s process?

Android can kill an app’s process while it is in the background and recreate the activity from a saved Bundle — an asymmetry iOS developers meet on day one of an Android port, documented in Google’s saved-state guidance. In this architecture the answer is uniform: state is serializable data, so the navigation spine is captured as a value in onSaveInstanceState and restored into the rebuilt tree — the anatomy page shows the dozen lines in the activity that do it. There is no per-screen inventory of what to save, because there is no per-screen state idiom.

Why does this suit AI coding agents?

An agent building a feature needs machine-checkable gates between its steps, or errors compound silently across a session. The scaffold’s loop supplies them in a fixed order: the feature spec, then the scenario, then the recording, then the reducer that must satisfy it, then the screens — with tools/duet verify as the same gate CI runs. An agent (or a person) cannot wire a screen to logic that has no passing recording, and a behavior regression fails the build on the commit that caused it rather than surfacing in QA. This staged order is how Modaal’s own agent builds every Duet feature — the Duet overview walks the six steps.

What are the measured results?

From Memory Lane, the shipped dual-platform app this site’s Duet pages quote, each number with its scope:
  • Warm test suite: 1–3 seconds for the dual-platform behavior suite at four-feature scale during the field migration — fast enough to run on every edit.
  • Mutation drill: 10 out of 10. Ten seeded logic mutations, each caught by fixture replay at the recording layer.
  • Recordings byte-identical across platforms: 6 of 6 in the adoption measurement — the Kotlin core replays the exact fixture bytes the Swift reducers recorded.
  • Sixteen Android screens, zero logic edits. The Android app’s product surfaces were built against a core whose recordings already passed; no fixture was re-recorded to make Android work.

When should you not use Duet?

Two cases are outside its lane today. Games and drawn playfields: no Duet card yet scaffolds a SpriteKit scene host, and a physics loop is not reducer-shaped work — Modaal’s 2D game / Interactive app template covers that ground, and Duet support for graphics-rich apps is planned. And teams that want to assemble a bespoke stack — their own DI framework, their own test strategy, per-screen architectural freedom — are choosing exactly the degrees of freedom Duet removes; a blank Android Studio project serves that intent better.

Common questions

It is in the same family — unidirectional flow with state as data and actions as the only inputs. What a generic MVI setup does not fix is where nondeterminism lives or what proves behavior: Duet adds the effect seam as a hard rule and recorded fixtures as the contract, replayed on both platforms in CI.
A ViewModel test exercises code against fakes you maintain, and it lives on one platform. A scenario drives a pure reducer with no fakes at all, and recording it produces an artifact — the fixture — that both the Android and iPhone implementations must replay byte-for-byte. The test suite is also a cross-platform agreement check, not only a regression net.
No. The project is a standard Gradle build; Android Studio opens it, Compose screens are ordinary Compose, and Jetpack libraries live where they always did — in the app module’s workers and screens. The constraint is only that feature logic stays in its module and reaches I/O through effects.
The state that must survive is serializable and rides the saved-instance Bundle; effects in flight are re-requested by the restored state where the feature models it that way. The scenario can pin that behavior — restoration after a system kill is a branch you record, not a bug class you meet in production.
Structurally yes — the Android app and the shared core work with no iOS target. The recording discipline still pays (fast pure tests, a mutation-resistant suite), but the cross-platform gate is Duet’s distinctive return; for a product that will only ever ship one platform, weigh the conventions against a lighter setup.
Modaal scaffolds Duet projects that start from this architecture instead of retrofitting it.

Sources and further reading

Inside a Duet Android app

The tree this page’s rules produce: feature modules, the thin app module, workers and the fixtures directory.

Duet overview

What Duet is, the wizard cards that scaffold it, and the six-step loop every feature follows.

Handling platform-specific UI

Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded.

Duet glossary

Reducer, worker, scenario, fixture, the checks — every term defined with one link each.

Duet tutorials

The rules on this page applied by hand: a pure reducer and its recordings in Tutorial 1, the worker harness in Tutorial 4, the mutation drill in Tutorial 6.
Last modified on September 9, 2026