Android Architecture Showcase
A single, runnable Android-only (Jetpack Compose) reference app that demonstrates a complete, idiomatic multi-module architecture - each convention shown in its own minimal-but-complete module. It is a teaching repo: the goal is not features but how the pieces fit together.
Data comes from the no-key Rick & Morty API. The app lists characters, opens a detail screen, renders that same list twice (Compose and classic Views), has a small MVVM About screen for contrast, and a dedicated error-handling demo.
Status: built milestone-by-milestone. Foundation, Core Infrastructure, the flagship MVI feature, Breadth & Contrast, and Quality & Docs are complete; the project assembles green and ships unit + UI tests. The only optional item left is the Room offline-cache stretch (see Optional: Room stretch).
Table of contents
- Stack
- Module structure & dependency rules
- The data → UI flow
- Presentation patterns: MVI vs MVVM
- One ViewModel, two renderers (Compose vs Views)
- Errors:
Result,DataError,UiText - Navigation
- Dependency injection (Koin)
- Testing
- Build & run (
androidCLI) - Optional: Room stretch
Stack
| Concern | Choice |
|---|---|
| Build | Multi-module Gradle + :build-logic convention plugins; a single version catalog (gradle/libs.versions.toml) is the only place versions live |
| Toolchain | AGP 9.0.1, Kotlin 2.3.20, Gradle 9.1, compileSdk/targetSdk 36, minSdk 24, Java 17 |
| UI | Jetpack Compose (Material 3) + one classic Views/XML renderer |
| DI | Koin 4.1 (constructor DSL) |
| Networking | Ktor (OkHttp engine) + KotlinX Serialization |
| Images | Coil 3 |
| Navigation | type-safe Compose Navigation (@Serializable routes) |
| Logging | Timber |
| Async | Coroutines + Flow |
| Testing | JUnit 5, MockK, Turbine, AssertK, kotlinx-coroutines-test, Ktor MockEngine, Compose UI test |
AGP 9 gotcha: AGP 9.0 has built-in Kotlin. Applying
com.android.application/libraryauto-applies the Kotlin Android plugin, so the convention plugins must not applyorg.jetbrains.kotlin.androidthemselves. Source lives insrc/main/kotlin.
Module structure & dependency rules
Modularized by feature first, then by layer (Clean Architecture: presentation → domain ← data).
Features never depend on each other; anything shared moves to a core module; :app wires the graph.
:app → wires everything; single Activity, Compose host, Koin start
:build-logic → Gradle convention plugins (the only place build config lives)
:core:domain → Result / Error / DataError, shared contracts (pure Kotlin)
:core:data → Ktor HttpClient factory + safe-call helpers (BuildConfig.BASE_URL)
:core:presentation → UiText, ObserveAsEvents, DataError → UiText
:core:design-system → AppTheme + reusable composables (AppScaffold, ErrorState, …)
:feature:characters:domain → models, CharacterRepository, GetCharactersPageUseCase (pure Kotlin)
:feature:characters:data → DTOs, mappers, KtorCharacterDataSource, NetworkCharacterRepository
:feature:characters:presentation → MVI ViewModels/State/Action/Event (UI-agnostic: no Compose, no Views)
:feature:characters:presentation-compose → Compose renderer (list, detail, error demo, nav graph)
:feature:characters:presentation-views → Views/XML renderer of the list (same ViewModel)
:feature:about:presentation → MVVM contrast screen
Dependency rules (enforced by what each convention plugin exposes):
| Layer | May depend on |
|---|---|
presentation |
own domain, core:domain, core:presentation, core:design-system |
data |
own domain, core:domain, core:data |
domain |
core:domain only - never data or presentation |
:app |
everything |
A key consequence: :core:presentation's UiText is Compose-free, and the compose convention
uses implementation (not api), so the UI-agnostic :feature:characters:presentation never gets
Compose on its classpath - which is what lets two different renderers share one ViewModel.
The data → UI flow
One request flows through every layer, each with one job:
Rick & Morty API
│ JSON
▼
CharacterDto / CharactersResponseDto (:data/dto) - serialization shape
│ CharacterMapper.toDomain() (:data/mappers) - DTO → domain, never the reverse leaks up
▼
Character / CharactersPage (:domain/model) - pure Kotlin domain model
│ CharacterRepository.getCharacters() (:domain contract, :data impl)
│ GetCharactersPageUseCase(page) (:domain/usecase) - domain operation (see note)
▼
CharacterListViewModel (:presentation) - holds State, processes Action, emits Event
│ Character.toCharacterUi() (:presentation/model)- domain → UI model (display shaping)
▼
CharacterUi in CharacterListState (:presentation) - immutable UI state
▼
CharacterListScreen / CharacterListFragment (:presentation-compose / -views) - dumb renderers
- DTOs (
*Dto) live indata; domain models are separate and never become DTOs/entities. Mappers are pure extension functions in amappers/package (toDomain()). - UI models (
*Ui) live inpresentationand carry display-ready data (e.g. blank detail fields pre-formatted to an em dash).
Note - when to add a UseCase
GetCharactersPageUseCase is intentionally a thin pass-through included to show the convention. The
rule it illustrates:
Add a UseCase when a screen needs business logic that doesn't belong in the ViewModel - real rules, or composition of several repositories/sources into one operation. When the ViewModel would merely forward a single repository call, injecting the repository directly is fine.
Here the list VM uses the UseCase; the detail VM calls CharacterRepository directly - both are
correct, and the contrast is the point.
Presentation patterns: MVI vs MVVM
Both patterns live side by side so the trade-off is concrete.
MVI (:feature:characters:*) |
MVVM (:feature:about:presentation) |
|
|---|---|---|
| State | one immutable State data class |
one immutable State data class |
| User input | a single onAction(Action) funnel + sealed Action |
plain public methods on the ViewModel |
| Side effects | one-time Events via a Channel (nav, snackbar) |
none - the screen calls a method / uses LocalUriHandler |
| Best when | state is complex and interacting; effects matter | the screen is small and mostly static |
The flagship list is MVI because its state is genuinely complex - pagination, loading vs.
next-page loading, error surfacing, SavedStateHandle restore after process death - and it emits
navigation/snackbar effects. About is deliberately MVVM: a StateFlow plus a couple of public
methods, with no Action and no Event types at all, because that ceremony would be pure
overhead for static content.
Note - Events vs State
State is what the screen is (re-rendered on every change, survives recomposition/rotation).
Events are things that happen once - navigate, show a snackbar. Modeling a one-time effect as
state causes it to re-fire on rotation; modeling durable data as an event drops it. MVI keeps them
separate (StateFlow vs Channel); the Compose side consumes events with ObserveAsEvents, the
Views side with repeatOnLifecycle.
Note - when MVVM is acceptable
Reach for MVI when state is complex and side effects matter. Reach for plain MVVM when the screen is small, mostly static, and has no real side effects - the About screen is the canonical case.
One ViewModel, two renderers (Compose vs Views)
:feature:characters:presentation is UI-toolkit-agnostic - no Compose and no Views dependency.
State stays Compose-stable via kotlinx-collections-immutable (ImmutableList) rather than the
@Stable annotation (which would pull in compose-runtime). The exact same CharacterListViewModel
(State/Action/Event/UI-model) is rendered twice:
:feature:characters:presentation-compose- Jetpack Compose (LazyColumn).:feature:characters:presentation-views-Fragment+ ViewBinding +RecyclerView/DiffUtil, resolving the same KoinCharacterListViewModelviaby viewModel().
:app hosts the Views renderer inside the Compose NavHost via AndroidFragment (Compose↔View
interop) and injects all navigation as callbacks, so the renderers stay decoupled from each other and
from navigation.
Material3-XML-theme gotcha: the host Activity (
MainActivity) extendsFragmentActivity(soAndroidFragmenthas aFragmentManager) and uses a Material Components XML theme, which the classic Views (e.g.MaterialToolbar,?attr/colorOnSurfaceVariant) require. A plainComponentActivityor a non-Material theme breaks the Fragment renderer.
Errors: Result, DataError, UiText
Expected failures are values, not exceptions. The whole app speaks one typed result:
sealed interface Result<out D, out E : Error> { Success(data) ; Error(error) } // :core:domain
sealed interface DataError : Error { enum Network { NO_INTERNET, NOT_FOUND, SERVER_ERROR, SERIALIZATION, … } ; enum Local { … } }
- The data layer catches transport/parse exceptions at the boundary (
safeCallin:core:data) and converts them toResult.Error(DataError.Network.*)- HTTP status → typed error, and a malformed body →SERIALIZATION(the cause chain is unwrapped because Ktor wraps the kotlinxSerializationException). Upper layers never see raw exceptions. - The presentation layer maps a
DataErrorto user-facingUiTextviaDataError.toUiText()(:core:presentation).UiTextis itself Compose-free (aStringResource/DynamicString), so a UI-agnostic ViewModel can holdUiText?in state; the renderer resolves it (asString()in Compose,asString(context)in Views).
The error-handling demo (overflow menu → "Error handling demo")
A runnable walk-through of the whole pipeline. Pick a failure to force; the ViewModel produces the
real DataError.Network, routes it through the same steps a genuine call uses, and the shared
design-system ErrorState renders it:
[Force: No internet] → Result.Error(DataError.Network.NO_INTERNET)
→ onFailure { … }
→ DataError.toUiText() = UiText.StringResource(R.string.error_no_internet)
→ ErrorState(message = uiText.asString(), onRetry = { onAction(OnRetry) })
Three distinct cases (NO_INTERNET, NOT_FOUND, SERVER_ERROR) each render their mapped message;
Retry re-issues the last request as an Action; a successful load clears the error. The same
ErrorState + retry Action is what the real list and detail screens use.
Navigation
Type-safe Compose Navigation with @Serializable route objects, one nav graph per feature, assembled
in :app.
@Serializable data object CharacterListRoute
@Serializable data class CharacterDetailRoute(val characterId: Int)
@Serializable data object ErrorDemoRoute
- Intra-feature navigation (list → detail, list → error demo) is driven by the
NavControllerpassed intocharactersGraph(navController, …). - Cross-feature / cross-toolkit destinations (About, the Views renderer) are exposed as lambda
callbacks supplied by
:app- a feature never imports another feature's route. - Nav args without a nav dependency: type-safe nav serializes
CharacterDetailRoute.characterIdinto the destination's arguments, which Navigation copies into the ViewModel'sSavedStateHandle.CharacterDetailViewModelreadssavedStateHandle.get<Int>("characterId")by field name - so the UI-agnosticpresentationmodule needs no navigation dependency. The sameSavedStateHandlealso persists the list's loaded page across process death.
Dependency injection (Koin)
One Koin module per feature layer (only if it has something to provide), all assembled in
ArchitectureApp - never inside feature modules. Prefer the constructor DSL:
// :feature:characters:data
val charactersDataModule = module {
singleOf(::KtorCharacterDataSource)
singleOf(::NetworkCharacterRepository) { bind<CharacterRepository>() }
}
// :feature:characters:presentation
val charactersPresentationModule = module {
factoryOf(::GetCharactersPageUseCase) // stateless UseCase
viewModelOf(::CharacterListViewModel) // same VM used by both renderers
viewModelOf(::CharacterDetailViewModel)
viewModelOf(::ErrorDemoViewModel)
}
The lambda form (single { … }) appears only where a constructor reference can't express the binding
- e.g.
single { HttpClientFactory.create(OkHttp.create()) }incoreDataModule(a factory call, not a constructor). Compose roots inject withkoinViewModel(); the Fragment usesby viewModel()- both resolve the sameCharacterListViewModelclass and supply itsSavedStateHandle.
Testing
Tests prove the architecture, not just the code. Stack: JUnit 5, MockK, Turbine (Flow),
AssertK, kotlinx-coroutines-test, Ktor MockEngine, and Compose UI test.
| What | Where | Kind |
|---|---|---|
GetCharactersPageUseCase |
:feature:characters:domain src/test |
pure JVM, JUnit 5 |
CharacterListViewModel, CharacterDetailViewModel |
:feature:characters:presentation src/test |
JVM unit, MockK + Turbine + SavedStateHandle |
NetworkCharacterRepository |
:feature:characters:data src/test |
JVM unit, Ktor MockEngine |
CharacterListScreen (robot) |
:feature:characters:presentation-compose src/androidTest |
instrumented Compose UI |
Conventions demonstrated:
- MockK for collaborators. The ViewModel/UseCase tests stub the
CharacterRepositoryinterface with MockK -coEveryscripts the suspend calls,coVerifyasserts the paging/retry interactions. - VM tested through its public MVI surface (State/Action/Event) with a directly-constructed
SavedStateHandle, so the same tests hold for either renderer. Coverage includes happy path, error →UiText+ snackbarEvent, pagination end-reached, process-death restore, and the rapid-duplicate-paging guard (which is why these useStandardTestDispatcher). - Repository tested over a real Ktor client with a swapped
MockEngine(HttpClientFactory.create(engine)): success mapping,404 → NOT_FOUND,500 → SERVER_ERROR, malformed body→ SERIALIZATION. - Robot pattern for the Compose UI test:
CharacterListRobotmethodsreturn thisso a test reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the rightAction.
JUnit 5 on AGP 9: the
de.mannodermaus.android-junit5Gradle plugin targets AGP 8.x, so this repo doesn't use it.AndroidUnitTestextends Gradle'sTest, so thearchitecture.android.unit.testconvention plugin just callsuseJUnitPlatform()and adds theunit-testbundle - including thejunit-platform-launcher, which Gradle 9 no longer bundles.
Espresso + API 34+: Compose's test rule drives Espresso's
onIdle, and transitive Espresso 3.5.0 calls the removedInputManager.getInstance(). The catalog pins espresso/runner to current versions in thecompose-ui-testbundle to fix that.
What runs where: ./gradlew test (all JVM unit tests) runs in CI; the instrumented Compose test
runs on a device/emulator via ./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest
(CI compiles it via assembleDebugAndroidTest). An Espresso test for the Fragment renderer is
possible but intentionally omitted (the VM logic is already covered by the shared unit tests).
Build & run (android CLI)
# Build
./gradlew assembleDebug # build the debug APK
./gradlew projects # print the module tree
./gradlew test # all JVM unit tests (JUnit 5)
./gradlew :feature:characters:presentation-compose:connectedDebugAndroidTest # Compose UI test (needs a device)
Using the android CLI for an emulator + run:
android emulator list # list AVDs
android emulator start <avd-name> # boot an emulator (returns when ready)
android run # build & deploy the app
android screenshot -o screen.png # capture the current screen
android layout --pretty # dump the UI tree (faster than a screenshot for debugging)
android docs search "<topic>" # search authoritative Android docs
Requires JDK 17+ (the Gradle build pins a Java 17 toolchain) and the Android SDK
(compileSdk 36, minSdk 24).
Optional: Room stretch
Out of core scope and not implemented (an optional stretch). It would add a room
convention plugin and a :core:database (or feature Room set) with CharacterEntity + DAO +
@Database (prefer autoMigrations), then convert the repository to offline-first
(OfflineFirstCharacterRepository: network → persist → expose a DB Flow; the ViewModel observes the
DB, never the network response). The current CharacterRepository returning the DataError
supertype already anticipates a multi-source implementation.