Skip to main content
In this tutorial you grow the splash from Tutorial 2 into a tree of eight features. A 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.
The manual setup below is what Modaal automates. Every tree this tutorial opens and every file it asks you to write is produced by the Duet templates in the Modaal new-project wizard, and a coding agent working in Modaal writes the feature, records it and runs the checks for you. This series walks the same ground by hand so you know what the scaffold emits and why: start a Duet project in the wizard when you would rather skip the setup.

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 a ports module, with 28 recordings under parity/fixtures/.
  • Four chain recordings, chain-root-splash, chain-root-signin, chain-main-signout and chain-profile-editname, each pinning a seam between two features.
  • Generated test doubles on both sides: <Name>EnvironmentMock classes from the KSP processor on the JVM, and <Name>DependencyMock classes and <Name>Component forwarders from tools/duet mocks in Swift.
  • The composition triple at every level of the tree on both platforms, and a passing composition test on each: RootFlowTest on the JVM and RootCompositionSpec in 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?

Open tutorial3-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:
The Kotlin lane reports 11 tests with one failure, 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

1

Declare the four ports

A port is an interface the logic calls and each app implements. The four live in one common-code module, 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.
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt
The value types above them, 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:
src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Callbacks.kt
Include the module in the settings file and check with (cd src-kmp && ./gradlew :ports:compileKotlinJvm -q).
2

Write the root level

The root is the app’s spine. Its state is a phase that names exactly one child, an auth snapshot, and a latch for a splash that finishes before the session is known. Its actions are its children’s delegate events, one case per child, plus the host’s AuthChanged report.
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
The reducer routes. A finished splash goes to the gate or straight to main by the snapshot, or sets the latch when the snapshot is still unknown; the gate’s completion signs the session in; a 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.
src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
The root has no effects yet, so 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.
3

Write the sign-in gate

The gate is one leaf written in full here; the other five, 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.
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt
The delegate event carries the display name the root will show, and the delegate effect’s serial name is 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.
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt
An empty address never reaches the port, a second tap while one sign-in is in flight is inert, and a success completes the gate with a name: the account’s saved one when the outcome carries it, and otherwise the email’s local part or Guest.
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt
The environment is the feature’s own seam, narrower than the auth port behind it; the effect handler waits on the port’s one callback and turns it into an action.
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInEnvironment.kt
src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInRuntime.kt
Write the serializers file as in Tutorial 1, the scenario with its four branches, and the golden test with one row per branch. The email branch pins the latch and the derived name:
src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInScenarioTest.kt
Add the manifest row and 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.
4

Generate the Kotlin test doubles

Tutorial 1 wrote its environment double by hand. From here the family’s KSP processor generates one per environment interface at test compilation, into 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:
src-kmp/subtrees/signin/logic/build.gradle.kts
The generated 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.
src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInTestStoreTest.kt
Run (cd src-kmp && ./gradlew :subtrees:signin:logic:jvmTest -q); the module reports 7 tests passing.
5

Pin the seams with chain recordings

A leaf recording pins one reducer. A chain recording pins the seam between two: the delegate one feature emits, received as the next feature’s action. Each 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.
src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootChainsTest.kt
The same file records 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:
parity/manifest.yaml
Record them with 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.
6

Write the mock services

Each port gets a mock service per platform: a class with canned data, answering inside the call, living in the app’s product sources. The two 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.
The Swift mocks live in a 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.
7

Compose the tree on Android

Every level gets the same three parts in one file named for the Builder. The Dependency names exactly what the level consumes from its parent; the Component forwards it in one 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.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SignInBuilder.kt
A parent supplies a child’s Dependency by conforming its own Component to it. 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.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt
Mounting from state is the fourth shell duty, after the three from Tutorial 2, and 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.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt
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.
8

Compose the tree on iOS

The Swift triple is the same three parts in the shell target of each feature. The Dependency carries two annotations for the mocks pipeline of the step after this one; the Component’s forwarders are generated from it, so the hand-written file holds the Dependency, the live environment, the environment factory as a Component member, and the Builder.
src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift
src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift
src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift
The root’s Component is hand-written because it owns objects, and a generated Component only forwards. Its two conformances are empty extensions: the members already line up.
src-ios/Libraries/FoyerKit/Sources/RootShell/RootComposition.swift
A parent shell mounts its child inside its own 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.
src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift
src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift
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.
9

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.
parity/manifest.yaml
Run 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:
src-ios/Libraries/FoyerKit/Sources/MainShell/Generated/MainShellComponents.swift
The files are build products with a fingerprint block; 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.
10

Render the tree

The render layer on each platform is one switch over the child the root holds. Each child’s view takes its own shell; nothing here decides anything.
The iOS app target shrinks to three files. 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.
src-ios/App/Foyer/SceneDelegate.swift
On Android, 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.
Two phone screens side by side showing the same sign-in screen: the Foyer title, the line Sign in to continue, an email field containing ann@example.com, a filled Continue with email button and a plain Continue as guest button.

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.

11

Test the composition roots

Each composition root gets one walk through the whole tree over the generated dependency mock. On the JVM the walk is headless, with every store on the test scope; in Swift it crosses the boundary on real time and settles on each phase change.
Each shell target also has a spec over its own generated mock: 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:
Two phone screens side by side showing the same account screen: a back chevron and the title Account, the display name ann, and two rows, Edit name and Sign out, with a tab bar for Home and Profile below.

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 ports module, each with its scenario, golden test and recordings; 28 leaf fixtures and four chain fixtures under parity/fixtures/.
  • The composition triple at every level on both platforms, with ChildSlot mounting 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:
src-kmp/subtrees/profile/logic/src/jvmTest/kotlin/dev/modaal/foyer/profile/ProfileEditNameChainTest.kt
Run 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

A hub gives every consumer every member, so every test double implements all of them, and it gives level-scoped objects no home. A per-level Dependency stays as narrow as the level’s reads, its double is a constructor-seeded bag of exactly those members, and deleting a member breaks the parent’s conformance at compile time. The Component is where a level’s own objects live, with the level’s lifetime; the Builder constructs it once per mount so nothing owned outlives the mount.
Because the Swift mock services implement the same Kotlin interfaces across the framework boundary, and a 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.
Each mock is a product source of one app, native to its platform, holding its own data. A shared mock-data module would be a module that outlives its content: the mocks are deleted in Tutorial 4 as the repositories land. Drift between the two MockAuth classes fails no check because behavior is pinned by the recordings; the mocks only answer.
In this tree the root routes: every action it receives is a child’s delegate event or the host’s auth report, and every transition is a state write. 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.
A hop decodes the delegate out of the previous step’s effects, and it finds the right effect by that case name. Naming it once per feature is what makes the hop mapping re-derivable at verify time from the recorded bytes rather than from a typed mapping the test author could get wrong. The Kotlin class can be called anything; NotifyHost stays.
One source. The gate’s reducer computes it when the outcome carries no saved name, the root keeps it in the auth snapshot, and the root shell reads it at the moment main is mounted and hands it down through the Builders to the profile tab and the account screen. A name saved in the editor climbs back as NameChanged. From Tutorial 4 the session stream carries it, so every reader updates through AuthChanged.

Sources and further reading

Tutorial 2: One Behavior, Two Apps

The framework boundary and the splash shells this tree grows from.

Tutorial 4: Workers

The on-device backend replacing these mock services, its two streams observed by workers adopted at mount, and the card that unlocks from a stream.

The Duet tutorial series

The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
Last modified on September 8, 2026