root level mounts exactly one child from its phase: the splash, then a sign-in gate, then main with a home tab and a profile tab; the profile tab mounts an account screen, which mounts a name editor. Every arrow up the tree is a child’s delegate event, received by the parent as one of its own actions; every arrow down is a mount decided by state. You write the composition triple, a Dependency, a Component and a Builder, at each level on both platforms, put four mock services behind four ports, and record the seams as chains so tools/duet verify fails when a shell forwards the wrong event. This is the third page of the nine-tutorial series.
What will you build?
The tree below, in Kotlin, with a SwiftUI app and a Compose app mounting it from the same core. Each box is a feature module with its own state, actions, reducer and recordings; the solid arrows are mounts, the dashed ones are delegate events climbing back up. The logic reaches the outside world through four ports: auth, purchases, items and account. In this tutorial each port is implemented by a mock service, a class with canned data written twice, once in Swift and once in Kotlin, as product sources of each app. Tutorial 4 replaces them one for one with the on-device backend. Expect about three hours; the Swift half is the longer one. You will have at the end:- Eight feature modules under
src-kmp/subtrees/, seven of them new, and aportsmodule, with 28 recordings underparity/fixtures/. - Four chain recordings,
chain-root-splash,chain-root-signin,chain-main-signoutandchain-profile-editname, each pinning a seam between two features. - Generated test doubles on both sides:
<Name>EnvironmentMockclasses from the KSP processor on the JVM, and<Name>DependencyMockclasses and<Name>Componentforwarders fromtools/duet mocksin Swift. - The composition triple at every level of the tree on both platforms, and a passing composition test on each:
RootFlowTeston the JVM andRootCompositionSpecin the Swift shells lane. - Both apps walking the same tree: splash, sign-in, the tabs, the account screen and the name editor.
Where do you start?
Opentutorial3-start from the duet-tutorials repository. It is Tutorial 2’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1. The toolchain is Tutorial 2’s: Xcode 26.6, a JDK 25, XcodeGen and the Android SDK with platform 36. Run the checks once before you edit anything:
Tutorial3ExerciseEditNameChainTest; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.
The steps
Declare the four ports
src-kmp/ports, together with the value types they carry. Every operation starts the work and returns; the result comes back through a callback that fires exactly once. That shape is deliberate: a Swift class can implement a Kotlin interface across the framework boundary only when its members are plain functions, and a suspend member cannot be implemented from Swift.SignInProvider, SignInOutcome, Plan, PlanOffer, PurchaseOutcome and Item, are @Serializable with the canonical sum serializers from Tutorial 1, because they appear inside feature state and actions and so inside the recordings. A one-line helper turns the callback shape back into a suspending call for the effect handlers:(cd src-kmp && ./gradlew :ports:compileKotlinJvm -q).Write the root level
AuthChanged report.SignOutRequested climbing from anywhere under main raises the gate again. A Completed that arrives after the splash phase is inert, because both splash paths notify and only the first one may move the app.RootEffectPayload is a sealed interface with no cases and the handler returns an empty flow; the type exists so the root has the kernel’s full shape and replays like every other feature. The module depends on the three children it mounts, splash, signin and main, because their delegate event types are its action payloads. Its scenario has five branches, one per row of the reducer; tools/duet record --feature root writes them.Write the sign-in gate
main, home, profile, account and editname, follow the same shape and are in the tree. The state holds an in-flight latch, the last failure and the provider in flight; the actions are the shell’s ContinueTapped and the environment’s SignInFinished.notifyListener. That name is a convention of the chain runner: at a hop it looks for the previous step’s notifyListener payload and decodes the delegate out of it. The splash from Tutorial 1 renamed its serial name to match in this tree; its Kotlin class and environment method keep their names.Guest.parity/feature-specs/signin.md, then tools/duet record --feature signin. Repeat for the other five leaves; when all seven rows are in, tools/duet lint reports 8 features and 28 fixtures.Generate the Kotlin test doubles
build/generated/ksp, so nothing is committed and a member added to the interface fails the next test compile. Wire it in each module that has an environment worth driving:SignInEnvironmentMock records every call and is seeded through one handler per method. The test-store suite drives the effect handler through it: the mock’s callback re-enters as SignInFinished, and the delegate reaches the sink.(cd src-kmp && ./gradlew :subtrees:signin:logic:jvmTest -q); the module reports 7 tests passing.Pin the seams with chain recordings
hop states the forwarding the parent’s shell performs in production; the recording marks the emitting step, and verify re-derives the mapping from the replayed payload, so an edited seam fails as structure drift. Node handles carry each node’s starting state, and the scenario names its fixture explicitly, because the toolchain finds the test by that quoted name.chain-root-signin and chain-main-signout, the sign-out climbing from account through profile and main to the root in three hops. They live in the root module’s tests because the chain’s last node is the root and the module already depends on every participant. Declare the fixtures in the manifest:tools/duet record --chain chain-root-splash and the two others; each participating feature’s spec must mention the chain by name, which verify checks. TUTORIAL_SKIP_STUBS=1 tools/duet verify now reports 31 fixtures and ends with duet verify: PASS.Write the mock services
MockAuth classes duplicate their rules on purpose. A shared mock-data module would outlive its content, and the mocks are deleted in Tutorial 4; drift between them fails no check because behavior is pinned by the recordings, not by the data.FoyerServices target of the consumer package and conform to the port protocols the framework exports, which is why the umbrella module now exports :ports alongside the feature modules. MockItems carries twelve rows, MockAccount holds the saved name in memory, and MockPurchases answers two plans that nothing reads until Tutorial 4.Compose the tree on Android
by clause, owns what is scoped to the level, and assembles the environment from its own members; the Builder constructs the Component once per mount and resolves nothing itself.AccountComponent conforms to EditNameDependency, ProfileComponent to AccountDependency, MainComponent to both tabs’ Dependencies, and the root Component, which owns the four services, to SignInDependency and MainDependency. Delete a member from any Dependency and the parent’s conformance stops compiling; that is what keeps the interfaces honest.ChildSlot from the shells package does it: at most one child, built when the key appears, torn down when it changes or clears. The root builder registers a slot keyed on the phase and observes the store with StateTransitions; each child’s delegate events route to the root store as actions, so the composition holds no listener of its own.ProfileBuilder and AccountBuilder register the same kind of slot on state.child; MainBuilder builds both tabs at once, because they live for the level’s lifetime. Each level’s mount class exposes its store and a StateFlow of the child it currently holds, and the composables render whatever is there. Run (cd src-kmp && ./gradlew :app:testDebugUnitTest -q) after the next step’s test; for now :app:assembleDebug compiles.Compose the tree on iOS
bind(), through a ChildSlot adopted between the store and the projection, so teardown unwinds the projection first, the child next and the store’s effects last. The root shell reads the state being applied rather than the mirror’s state, because a @Published property publishes before it assigns and the mirror still holds the previous phase during the projection.AccountViewShell and ProfileViewShell do the same over state.child, with a factory closure the Builder hands them over the level’s Component; MainViewShell activates both tabs in its bind() and adopts their deactivation. Add one shell target and one test target per feature to Package.swift, and re-point the app’s XcodeGen spec at the RootShell product.Generate the Swift Components and mocks
tools/duet mocks runs the family’s Sourcery templates over the shell targets from rows in the manifest: one row generates a level’s Component from its DuetComponent-annotated Dependency into the target, another generates the Dependency’s test double from CreateMock into the test target. The bundle tag pins the release that carries the engine, the templates and the CLI together.tools/duet mocks once; it downloads the bundle and writes thirteen files under Generated/. A generated Component is one forwarder per member, in name order:tools/duet mocks --check re-hashes the inputs and fails on a hand edit or a stale file without running the engine. The per-tree gate runs it whenever the manifest has a mocks: section.Render the tree
SceneComponent is the root Dependency’s conformer, two lines; the scene delegate builds the root over it, hosts RootView, and activates the root shell once the window is visible.MainActivity keeps the root mount on the retained scope as in Tutorial 2 and renders AppRoot over it. Build both apps: xcodegen generate and an Xcode build for the iOS app, ./gradlew :app:assembleDebug for the Android one. Both show the splash, then the gate.
The sign-in gate with an address typed, on an iPhone 17 simulator (left) and a Pixel 8 API 36 emulator (right); tutorial3-complete at Duet 0.7.0, duet-tools 0.24.0.
Test the composition roots
AccountViewShellSpec mounts the editor from state and watches a save climb back as NameChanged. Run parity/scripts/apple-boundary-lane.sh; it assembles the framework, replays all 28 recordings across the boundary (30 tests with the two error-channel rows), and reports shells: 14 test(s) executed. Then (cd src-kmp && ./gradlew :app:testDebugUnitTest -q) for the two JVM walks. Sign in on both apps, open the Profile tab and the Account row:
The account screen inside the profile tree, on an iPhone 17 simulator (left) and a Pixel 8 API 36 emulator (right); tutorial3-complete at Duet 0.7.0, duet-tools 0.24.0.
What you now have
- Eight feature modules and the
portsmodule, each with its scenario, golden test and recordings; 28 leaf fixtures and four chain fixtures underparity/fixtures/. - The composition triple at every level on both platforms, with
ChildSlotmounting the root’s, the profile tab’s and the account screen’s children from state. - Four mock services per platform behind the four ports, in product sources, to be replaced in Tutorial 4.
- Generated doubles on both sides, and a green
tools/duet mocks --check. - Two composition tests walking the whole tree, and both apps walking it too. The tree is the app; the screens are its projection.
Exercise: record the edit-name chain
tutorial3-start carries Tutorial3ExerciseEditNameChainTest, a failing placeholder in the splash module. Delete it and write ProfileEditNameChainTest in the profile module’s jvmTest, where the chain’s last node lives. The chain starts at the editor with a save in flight and pins two hops: Saved("Ann B") crossing into the account screen as EditName(event), whose reducer takes the name and emits NameChanged, then that delegate crossing into the profile tab as Account(event), whose header shows the new name and emits nothing. Add chain-profile-editname to the manifest’s chains: list and to the three participating specs, then run tools/duet record --chain chain-profile-editname. The finished hop reads:
tools/duet verify; the Kotlin lane replays 32 fixtures and the run ends with duet verify: PASS. tutorial3-complete carries the finished test.
Common questions
Why a Dependency per level rather than one app-wide interface?
Why a Dependency per level rather than one app-wide interface?
Why do the ports use callbacks instead of suspend functions?
Why do the ports use callbacks instead of suspend functions?
suspend member cannot be implemented from Swift. A plain function with a one-shot callback can, and awaitCallback turns it back into a suspending call inside the effect handler. Tutorial 4’s Kotlin repositories keep the shape so the ports do not change when the mocks go.Why are the mock services written twice?
Why are the mock services written twice?
MockAuth classes fails no check because behavior is pinned by the recordings; the mocks only answer.Why does the root have no effects?
Why does the root have no effects?
RootEffectPayload has no cases so that the root keeps the kernel’s shape and replays through the same runner as every leaf; Tutorial 5 adds its first case, the deep-link forward.Why does the chain runner need the serial name notifyListener?
Why does the chain runner need the serial name notifyListener?
NotifyHost stays.Where does the display name come from?
Where does the display name come from?
NameChanged. From Tutorial 4 the session stream carries it, so every reader updates through AuthChanged.Sources and further reading
- The Duet framework repository —
docs/composition.md, the Dependency, Component and Builder rule this page applies;ChildSlot,ChildStoresandStateTransitionsin the shells packages; the chain scenario dialect inkernel-testandDuetTesting;contracts/mock-dialect-v1.md, the member vocabulary both generated doubles share. - The duet-tools repository —
contracts/manifest.mdfor thechains:andmocks:sections, and therecord --chain,mocksandmocks --checkverbs. - kotlin-ksp-mocks — the KSP processor behind
kspMocksTargets. - swift-sourcery-templates — the
ComponentandMockstemplatestools/duet mocksruns. - The duet-tutorials repository —
tutorial3-startandtutorial3-complete, and the checks CI runs on them. - The Duet glossary — delegate, mount, host, scenario and fixture.