The parts of a feature
feature
A unit of behavior declared as state, actions, effect payloads and a pure reducer, carried as one row in the parity manifest and checked by its own behavior recordings. See how a feature gets built.reducer
Where every decision happens: for each action the Store feeds it, the reducer alone runs the guards, writes state and returns effects. It is pure — no platform calls, no minted time or identity — so its behavior replays byte-for-byte on both platforms.effect / effect payload
What a reducer returns beside the new state: a value naming work the platform must do, such as a wait on a clock, a port call or a delegate notification, never the work itself. The effect handler turns each payload into the actions it produces, and an effect id gives it cancel-in-flight semantics. Because an effect is data, a behavior recording pins it byte for byte. Tutorial 1 writes the first two; Tutorial 9 adds an analytics event as one more.the effect handler
The module’s only impure code: it takes each effect payload the reducer returned and turns it into the stream of actions that payload produces, doing the work through the environment. It receives a reducer’s effects in the order the reducer returned them, and that order is part of what a behavior recording pins. Tutorial 1 writes the first one, beside the store factory both shells later call.the environment
The interface of seams a feature’s work goes through — the clock, identity, storage, the feature’s ports — held by the effect handler and consulted nowhere else; the reducer never sees it, which is what lets a recording replay without one. A feature with no seam has no environment, and that is a valid shape. Its mechanical test double is generated rather than hand-written; a hand-written fake enters only where the double needs behavior of its own, such as driving a port’s streams. Tutorial 1 declares the first environment against a hand-written double; Tutorial 3 switches to generated ones.Store
Built once per mount: it holds the feature’s state, takes actions, runs the reducer, and hands returned effects to the effect handler. Nothing else runs feature logic. Tutorial 1 builds the first one and drives it through the kernel’s exhaustive test store.kernel
The runtime every Store is built on: it runs each action through the reducer, tracks returned effects in flight, and owns the effect-leak check at teardown. One kernel shape serves both platforms; its contract ships with the framework.shell
What stays on the platform side once logic has moved: three duties and no decisions — bind the view’s intents to the store, project state into the view, bracket the lifecycle. See what crossing one feature looks like; Tutorial 2 writes the first Swift and Compose shells.host / host root
The level that mounts a feature: it builds the store, owns the slot the feature renders in, and takes its delegate events as data. In the Swift flavor a host root is the package whose resolved dependencies must contain the Duet family and nothing else. Tutorial 3 mounts a tree of features from state, one level hosting the next.delegate events / the delegate seam
How a feature reports an outcome upward: the reducer emits a plain enum case, and the host receives it as data on the seam it handed in at build time. The event is a value, so a scenario asserts it byte-for-byte. Tutorial 3 receives each child’s delegate events as one case of the parent’s action type.Interactor
The CombineRIBs type that owns a RIB’s business logic. In a migration every Interactor shrinks to a shell and is then deleted. See what happens to each piece of your code.worker
Stateful, lifecycle-bound processing on the environment side, adopted at mount: its results re-enter feature state only through declared seams, and cancellation is its stop. Workers process, reducers decide; a worker has no golden recording. Tutorial 4 writes the first two and tests them with the worker harness.the shared core
The one place a feature’s logic lives in the Kotlin Multiplatform flavor: KotlincommonMain modules under src-kmp/, one subtree module per feature. Android compiles it directly, and iOS consumes the same compiled logic as one prebuilt framework Gradle assembles, so the reducer the iOS app runs is the one the Kotlin lane checked rather than a second copy of it. That framework links the Kotlin/Native garbage collector into the iOS app beside Swift’s ARC; Kotlin/Native GC and Swift ARC covers who frees what. Wiring the core is the migration step that links it into an existing iOS build, and single-source is the shape it exists to produce. Tutorial 2 builds the first one into an Apple framework and replays the recordings across it.
package
In the Swift flavor a feature’s subtree holds two Swift packages: the gated feature half resolves the Duet family and nothing else, so the checks build it in isolation; the app-facing node half is where the app connects.port
An interface the shared logic calls and each app implements, such as the auth, purchases, items and account ports in the tutorials’ app. Every operation starts work and returns, with the answer arriving through a callback that fires once, so a Swift class can implement it across the framework boundary; a stream on a port is a sticky value a worker observes. Tutorial 3 declares the four ports behind mock services; Tutorial 4 adds the streams and replaces the mocks with an on-device backend.slice
A value a parent publishes downward out of its own state, delivered to a child as an action of the child’s own: the child reads it and never writes it, and nothing below the parent holds the parent’s Store. Tutorial 4 projects the entitlement to two tabs as a slice.lateral state
A value that must cross between sibling features, neither of which holds the other’s store: one worker owned by their lowest common ancestor, with a void update on one side and a sticky observation on the other. Tutorial 5 builds one for the onboarding steps’ readiness.@MainActor
The escalation a handler or worker takes to cross an isolation boundary. Marking a type unchecked-Sendable instead silences the compiler without proving anything, which is why the doctor pass flags it on a worker.
the fixture-authoring language
The four verbs a scenario is written in — given, when, then, then-effects — with a Swift and a Kotlin spelling. The framework’s pages call it the scenario DSL.Recording and checking
the parity manifest
parity/manifest.yaml at the repository root: one row per feature naming the file that implements it, its state, action and effect-payload types, the scenario its recordings compile from, and the recordings it owns, plus the generator rows for the tree’s generated doubles and the divergence waivers the parity contract keeps. The file’s presence is what marks a tree as a Duet tree, and its header comment names the flavor. Every check is derived from it and nothing is guessed at: the lanes, which platform lanes run, and which recordings must report a replay. Tutorial 1 declares the first feature in it; Tutorial 6 reads the lanes it derives.
the feature spec
One page per feature in prose, underparity/feature-specs/, in eight fixed sections: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. It is where a behavior change is written first, before the scenario that makes it executable. The tool checks that every recording the manifest lists is named in the spec in backticks, so the prose cannot drift from the recordings without a red check; where spec and recording disagree about behavior, the recording wins. Tutorial 1 writes the first one, and the readiness audit drafts one per feature from a legacy app.
scenario
One test in the fixture-authoring language, pinning one outcome. Mutually exclusive endings are branches over one starting state; a chain scenario pins a delegate seam that crosses features. Tutorial 1 writes the first scenario and records it.behavior recording
What recording a scenario produces: a checked-in file replaying the scenario’s exact state and effect bytes — a build product, never hand-edited, and it wins any disagreement with the feature spec’s prose. The tools, the manifest and the framework’s pages call these files fixtures.canonical bytes
The one JSON dialect both platforms encode to, so a recording can be compared as text rather than as two language-specific object graphs: keys sorted at every nesting level, no insignificant whitespace, a nil-valued key omitted rather than written asnull, dates as ISO-8601 UTC with exactly three fractional digits, UUIDs lowercased, and a sum type written as an explicit case-and-value pair instead of either language’s default encoding. Both sides of every comparison are re-encoded through the same writer before they are compared, so a committed file may be pretty-printed for a reader without changing what it pins. Floating-point values are not allowed in recorded types, because no cross-platform representation rule is pinned for them yet. The framework’s pages call this canonical serialization.
replay
What the checks do with a committed behavior recording: seed the recording’s initial state, send each recorded action through the reducer, compare the canonical state and effect bytes against the recording’s expectations, and stop at the first step that differs, so a red names the recording and the step. The same files replay in three places — a feature’s platform lanes, where the golden test reads the committed files at runtime; the Apple boundary lane, which replays them across the framework the iOS app consumes; andduet protocol-run, which drives them into a replay-runner binary over the framework’s replay protocol. Replay checks the reducer against the recorded bytes; re-recording in check mode runs the opposite direction, checking the recorded bytes against the scenario. Tutorial 1 writes the golden test and adds the replay runner; Tutorial 2 replays the same four recordings across the boundary; Tutorial 6 lists which command replays what.
golden recording
A behavior recording in its replay role: the golden test reads committed recordings at runtime and fails on any byte that differs — a role, not a second kind of file. A worker has no reducer to replay, so it has no golden.chain recording
A behavior recording that pins the seam between two features rather than one reducer: the delegate event one reducer emits, received by the next as its action, with each hop re-derived at verify time from the recorded bytes so an edited seam fails as structure drift. Tutorial 3 records the first four.re-recording
Running the recorder again after a deliberate behavior change, then reading the recording diff like code. The run deletes before it harvests, so a leftover file means a stale run.metadata-only
The pass condition for a re-record that must not change behavior: the diff touches scenario source locations and step labels, and zero state or effect bytes.the test store
The exhaustive harness for what a recording does not cover: it drives the real Store, effect handler and environment together on a virtual clock, so timing and effect lifetimes are testable without waiting. Every state change must be asserted and every effect expected — an unreceived action or an effect still running fails atfinish(). Tutorial 1 pins the splash’s one piece of timing on it.
the worker harness
The only sanctioned test bracket for a worker, and what stands in for the golden recording a worker cannot have: start the worker, drive the streams it observes, cancel it, andfinish() — which fails when the worker’s run has not returned after cancellation, the leak a hand-rolled start-then-cancel bracket does not catch. Tutorial 4 tests the first two workers with it.
mutation drill
The negative control on the checks: break a reducer guard and watch the replay go red, then break a recording file and watch the test task go red — proof the checks are wired to what they claim to cover. Run in a crossing’s first half; Tutorial 6 runs it from a mutation table in both directions.the checks
What must pass before work is done, run by one command locally and in CI: the manifest lint, the recording check, the cross-platform verification and the doctor pass. The framework’s pages call each one a gate. Tutorial 6 lists every check with what it proves and puts them in one CI workflow.lane
One slice of a checks run, rooted at a specific package or module set, so a red names its platform and its root. Parity lanes derive from a feature’s manifest row; generation lanes produce the test doubles and DI components. Tutorial 6 reads the lanes report the tool derives.the Swift and Kotlin lanes
A manifest row’s two implementation declarations; which of them a row carries tells the checks which platform lanes to run — one during ordinary work, both inside a coexistence window.the manifest lint
The manifest’s own consistency checks — the fast red, run on every manifest edit. The declaration-parity and subtree-geometry checks join only while a row declares both implementations.the doctor pass
The check that reads the tree rather than the manifest: it confirms the project’s own declaration file still matches the tree it describes, and flags the shortcuts the byte checks cannot see — a worker stamped unchecked-Sendable to silence the compiler instead of taking the escalation. A red names the declaration row, or the worker. Tutorial 6 lists it beside the other nine commands with what each one proves.
the headless test runner
A script every scaffolded project ships that runs the iOS test suite from the command line, wrapping Apple’s xcodebuild — for verifying outside the Modaal app and reproducing CI failures locally.the run verb
The build-and-launch action in a Modaal session: one command that builds the selected target and launches it on a simulator, emulator or device. The action’s name differs between session types, so Duet’s documents say “the run verb”.The migration, step by step
the readiness audit
A static census of a legacy app’s distance to the target shape, producing the readiness report, the migration tracker and a feature spec draft per feature. The counts are pointers to verify, not verdicts. See the stages, step 1.the migration tracker
One row per surface: a derived status, four sizing columns, and the notes the per-feature plan is written into. The tools and the migration article call it the ledger — distinct from the divergence waivers the parity contract keeps.updating the tracker
Re-running the readiness audit in write mode: it creates or merges the tracker and refreshes the computed fields. It is the route’s first step after the stop-digging edit.the graft
The one operation that adds the shared core’s Kotlin plane, the Android app shell and the parity workspace beside an existing tree while editing none of its sources — the checks exist before any behavior can change. See the stages, step 2.wiring the core
Run once the graft’s checks are green: it links the assembled shared core into the iOS app’s build, so every later flip is feature work with no build wiring left to add. See the stages, step 2b.feature crossing
One feature’s move onto the shared core, end to end: spec, Swift twin, Kotlin twin, the Android mount, the flip. See what crossing one feature looks like.the coexistence window
The stretch of a crossing in which a feature’s manifest row declares both implementations: both twins replay the same recordings and the parity lints run. A valid resting state for as long as the repository wants.the flip
The one change that closes a coexistence window: delete the Swift twin, make the feature’s manifest row Kotlin-only, verify both platforms.twin
One of a feature’s two implementations while its coexistence window is open — the Swift twin inside the app, the Kotlin twin in the shared core — replaying the same behavior recordings. In a Duet Twins project, in development, both twins are permanent.single-source
The destination shape: one implementation of a feature’s logic, in the shared core, consumed by both apps. The Swift flavor’s single-core is the same one-copy rule before a second platform consumes it. See the two Duet templates.The work items and the field kit
surface
A tracker row’s unit of work: one RIB or one legacy worker in the app being converted, typed by the row’s kind.reference test
The existing test a conversion is written against, ported one-to-one onto the deterministic test store or the worker test harness. A surface without one earns its reference test first; the audit’s sizing table and the tracker call it the oracle.look-and-feel work
The part of a surface that does not move: tuned rendering, animation timing, gesture handling — re-authored on the other platform and sized by counting files. The audit calls it platform-manner mass; manner is the contract layer it lives in.the note format
The shape every tracker note takes: a capitalized tag, an em dash, then clauses following the surface’s kind — a RIB’s note carries a crossing position; a worker’s names the crossing it rides with.the stop-digging rule
From step 0 on, a new feature in the legacy app is authored in the target shape — a reducer plus a thin shell, never a new Router — and new background work is a Duet worker from day one. See the stages, step 0.adoption seam
Where a converted feature’s SwiftUI view attaches to the legacy UIKit composition: a themed hosting controller takes the legacy child’s slot, and activating the shell replaces attaching the child.coexistence kit
The migration package that stands for the migration’s duration — the worker shim and the dual-import discipline — and whose dependency edge is deleted at completion. See the finish line.stage host
What the Android app’s root grows into when the app needs real navigation: one full-screen child chosen from route state, transitions as reducer edges, back handling in a documented order. The first crossing that needs a screen change grows it.mount
Putting a feature into the app’s composition: its Store is built once at the mounting level, its screen renders from the mount row, and that level’s own workers are adopted there.spine
The app’s navigation trunk, in two uses: the route state that survives process death, and the root and navigation surfaces that cross last, after the leaves. Tutorial 5 gathers a route spine and rebuilds the tree from it.Common questions
Why does my repository say fixture where this page says behavior recording?
Why does my repository say fixture where this page says behavior recording?
They are the same file. The headings here use the documentation’s names; the tools, the parity manifest and the framework’s pages use the framework-side names — fixture, gate, ledger, oracle — and each entry states both: behavior recording, the checks, the migration tracker, reference test.
How long can a coexistence window stay open?
How long can a coexistence window stay open?
As long as the repository wants — the window is a valid resting state, and the parity lints keep both twins honest while it is open. Holding one open costs the dual-language authoring premium the migration article quantifies; the flip closes it.
Where is each term treated in full?
Where is each term treated in full?
Three articles carry the depth: the Duet overview for the feature shape and the two templates, handling platform-specific UI for the presentation contract, and migrating a CombineRIBs app for the route. The framework’s own reference lives in the Duet repository. The nine Duet tutorials build each feature-level term by hand, and the series index maps every concept to the step that treats it.
How do I start a Duet project?
How do I start a Duet project?
In Modaal, say you’re building a Production app — for Android + iOS or for iOS — in the sentence on the home screen. Starting a Duet project walks through the choice.
Sources and further reading
- The Duet framework repository — the framework API, the emitted contracts and the family’s own documentation.
- Kotlin Multiplatform documentation — the technology under the Kotlin Multiplatform flavor’s shared core.
- Swift concurrency documentation — the isolation model behind the
@MainActorentry.
Read next
Duet: one shared core, two native apps
The product overview: what a Duet project is, the two templates, and how a feature gets built.
Handling platform-specific UI
The five-layer presentation contract: kinds, manner, islands, and how deliberate divergence is recorded.
Migrating a CombineRIBs app
The route the migration cluster’s entries walk: audit, graft, crossings, the finish line.
Duet tutorials
Feature, reducer, shell, worker, scenario, recording and the checks, each built by hand across nine tutorials.