Skip to content

feat: add the MVI base classes - #136

Merged
j0ker70 merged 6 commits into
mainfrom
feat/mvi-base
Oct 19, 2026
Merged

j0ker70 merged 6 commits into
mainfrom
feat/mvi-base

Conversation

@j0ker70

@j0ker70 j0ker70 commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #25.

What changed and why

This adds the MVI base classes in :core:mvi, sets up unit tests in every module, and writes the MVI contract down.

The MVI base (core/mvi/src/main):

  • MviState, MviIntent and MviEffect mark each screen's types. The Mvi prefix keeps them from clashing with Compose's State and Android's Intent.
  • Reducer is a pure function that returns Next: the new state and any one-time effects.
  • MviViewModel runs every intent through the reducer. It exposes state and pendingEffects, both StateFlows. Each effect gets a unique id and stays pending until the UI calls effectHandled(id). So a stopped UI or a cancelled handler gets it again. Pending effects survive configuration changes, not process death.
  • Work results come back through dispatch as intents, such as Loaded, so every state change goes through the reducer. Subclasses start work in runSideEffects.
  • MviViewModel lives in the plain Kotlin/JVM module. It compiles against Lifecycle's JVM build, lifecycle-viewmodel-desktop 2.11.0. Its ViewModel and viewModelScope API is the same as the Android build's: I compared both with javap.

The example (core/mvi/src/test) is a counter in test code only. It has 4 reducer tests and 5 ViewModel tests: state updates, an async load result coming back through the reducer, an effect staying pending until effectHandled, effectHandled removing only that effect, and several effects keeping their order.

Unit test setup (build-logic/.../Testing.kt): tests use kotlin.test everywhere. Plain Kotlin/JVM modules run them on JUnit 6, and Android modules on JUnit 4, which Robolectric and Compose's test rules need. Both get kotlinx-coroutines-test. The JVM setup adds the JUnit Platform launcher explicitly, because Gradle 9 no longer adds it.

ADR 0002 is amended in place, as decided: the reducer returns the new state and any one-time effects, intents include work results, and effects are pending UI state until the UI reports them handled, following Android's UI events guidance. Its status stays Accepted. As you decided after the first review, a Consequences line now says that the UI must report each effect as handled, and that pending effects are lost on process death. The roadmap's Phase 2 guide had one sentence saying the reducer returns only a new state, which I changed to match.

Docs: The MVI contract in docs/architecture.md, Running tests locally in docs/testing.md, and new MVI, test and command lines in AGENTS.md. The Android API check section now says what Animal Sniffer checks, since :core:mvi is the first module with classes.

Checks run locally:

  • The all-checks command passes, and a second run reuses the configuration cache.
  • :core:mvi:test: 9 tests, 0 failures, 0 skipped.
  • animalsnifferMain ran on :core:mvi's 7 classes with no violations. A temporary call to java.util.Optional.isEmpty() (API 33) failed it with Undefined reference: boolean java.util.Optional.isEmpty() against gummy-bears-api-29. The probe wasn't committed.
  • A temporary kotlin.test test in :core:data ran on JUnit 4, passed, then failed the build when broken. It wasn't committed.
  • :app's release runtime classpath is unchanged (lifecycle 2.9.4, coroutines 1.9.0), because nothing in the app depends on :core:mvi yet.

New dependencies

No new Gradle plugins. Licenses are taken from each POM.

Artifact Version License Ships in the app
androidx.lifecycle:lifecycle-viewmodel (JVM build -desktop) 2.11.0 Apache 2.0 Not yet. It will ship as lifecycle-viewmodel-android once a feature module uses :core:mvi, replacing 2.9.4.
org.jetbrains.kotlinx:kotlinx-coroutines-core (-jvm, plus kotlinx-coroutines-bom) 1.11.0 Apache 2.0 Not yet. Same as above, replacing 1.9.0.
androidx.annotation:annotation-jvm 1.9.1 Apache 2.0 Already ships, same version
androidx.collection:collection-jvm 1.5.0 Apache 2.0 Already ships, same version
org.jetbrains:annotations 23.0.0 Apache 2.0 Already ships, same version
org.junit:junit-bom, junit-jupiter-api, junit-jupiter-engine, junit-platform-commons, junit-platform-engine, junit-platform-launcher 6.1.3 EPL 2.0 No, tests only
org.opentest4j:opentest4j 1.3.0 Apache 2.0 No, tests only
org.apiguardian:apiguardian-api 1.1.2 Apache 2.0 No, tests only
org.jspecify:jspecify 1.0.0 Apache 2.0 No, tests only
org.jetbrains.kotlin:kotlin-test, kotlin-test-junit5, kotlin-test-junit 2.4.20 Apache 2.0 No, tests only
org.jetbrains.kotlinx:kotlinx-coroutines-test (-jvm) 1.11.0 Apache 2.0 No, tests only
junit:junit 4.13.2 EPL 1.0 No, tests only
org.hamcrest:hamcrest-core 1.3 BSD 3-Clause No, tests only

Test libraries never ship, so they fall under the build tool rule in AGENTS.md.

Checklist

  • OpenSpec change archived, or not needed. Not needed: the MVI base is internal structure with no change in app behavior.
  • docs/architecture.md updated, or the structure of the app didn't change
  • ADR added for any decision that would be hard to reverse, or there was none. ADR 0002 is amended in place, as the maintainer decided.

Reviewer

Final verdict: Ready

  • First round: Ready, nothing to fix. It suggested five changes, all fixed in 84ffb4a: limit test names with spaces to local tests (Android runs them only from API 30), note the Main dispatcher exception next to AGENTS.md's inject-dispatchers rule, cite Animal Sniffer's source for how it treats invokedynamic calls, add imports to the effect sketch so it uses the non-deprecated LocalLifecycleOwner, and say that a state's initial value doesn't come from the reducer.
  • Second round, on that commit: Ready, with no new findings.
  • Third round, on the ADR 0002 Consequences line: Ready, with no new findings.
  • Questions for the maintainer:
    1. Answered: until a feature module depends on :core:mvi, Android modules' unit tests run on kotlinx-coroutines 1.11.0, through kotlinx-coroutines-test, while the APK ships 1.9.0. The maintainer accepted this until a feature module depends on :core:mvi.
    2. Merging closes Write the MVI base classes #25 and with it the comment asking for the failing-test proof. That proof is done below, before merge.

Failing-test proof

Done in #137, a ready pull request from this branch with one deliberately failing test added to CounterReducerTest:

This repeats Phase 2's "Done when" with a real failing unit test, as the comment on #25 asked. #126 proved it earlier for a failing detekt check.

🤖 Generated with Claude Code

- Plain Kotlin/JVM modules run JUnit 6 through the JUnit Platform, the
  setup Kotlin's docs use, which also brings parameterized tests for the
  YouTube layer's saved responses later.
- Android modules run JUnit 4, AGP's default, because Robolectric and
  Compose's test rules need it and JUnit 6 deprecates the engine that
  would run JUnit 4 tests.
- Both use kotlin.test's annotations and assertions, so a test reads the
  same in either kind of module.
- Add the JUnit Platform launcher explicitly, because Gradle 9 no longer
  puts it on the test classpath.
- Add kotlinx-coroutines-test to both, since most tests will cover
  coroutine code.
- MviState, MviIntent and MviEffect mark each screen's types. The Mvi
  prefix keeps them from clashing with Compose's State and Android's
  Intent in screen code.
- The reducer returns Next, the new state plus any one-time effects, so
  the decision to show an effect stays in pure, tested code.
- MviViewModel runs every intent through the reducer and holds effects
  as pending state with unique ids until the UI calls effectHandled, as
  Android's UI events guidance recommends, so a stopped UI or a
  cancelled handler still gets them.
- Work results come back through dispatch as intents, so every state
  change goes through the reducer.
- The ViewModel base lives in the plain Kotlin/JVM module, built against
  Lifecycle's JVM build, whose ViewModel API matches the Android build.
- A counter example in test code shows the whole pattern, with reducer
  and ViewModel tests.
- The reducer returns the new state and any one-time effects, so effect
  decisions stay in pure code that unit tests cover.
- Intents include the results of work the ViewModel runs, so a finished
  load reaches the screen through the reducer.
- Effects stay pending as UI state until the UI reports them handled,
  as Android's UI events guidance recommends, because a channel doesn't
  guarantee delivery when the ViewModel outlives the UI.
- Fix the roadmap's description of a reducer to match.
- Fill in the MVI contract in architecture.md: the types, how an intent
  flows, effects as pending state, dispatchers, and why the ViewModel
  base fits in a plain Kotlin/JVM module.
- Explain the two JUnit versions in the build settings, and what the
  Android API check does and doesn't check now that a module has code.
- Fill in Running tests locally in testing.md, so later screens follow
  the same test pattern.
- Add the MVI and test rules, and the one-module test commands, to
  AGENTS.md.
- Limit test names with spaces to local tests, because Android runs
  such method names only from API level 30 and the minimum is 29.
- Note the Main dispatcher exception next to the inject-dispatchers
  rule in AGENTS.md, where agents look for it.
- Cite Animal Sniffer's source for how it treats invokedynamic calls,
  and split the long Android API check paragraph into a list.
- Add imports to the effect-handling sketch, so a copy uses
  androidx.lifecycle.compose's LocalLifecycleOwner, not the deprecated
  one in Compose UI.
- Say that a state's initial value doesn't come from the reducer.
- Record that the UI must report each effect as handled and that
  pending effects are lost on process death, so the ADR's consequences
  match its amended decision.
@j0ker70
j0ker70 merged commit 173f0b9 into main Oct 19, 2026
1 check passed
@j0ker70
j0ker70 deleted the feat/mvi-base branch October 19, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Write the MVI base classes

1 participant