Adrian Kuta d1ff0e30ba
Some checks failed
CI / build (push) Has been cancelled
Initial commit
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00
2026-06-11 11:03:01 +02:00

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

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/library auto-applies the Kotlin Android plugin, so the convention plugins must not apply org.jetbrains.kotlin.android themselves. Source lives in src/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 in data; domain models are separate and never become DTOs/entities. Mappers are pure extension functions in a mappers/ package (toDomain()).
  • UI models (*Ui) live in presentation and 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 Koin CharacterListViewModel via by 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) extends FragmentActivity (so AndroidFragment has a FragmentManager) and uses a Material Components XML theme, which the classic Views (e.g. MaterialToolbar, ?attr/colorOnSurfaceVariant) require. A plain ComponentActivity or 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 (safeCall in :core:data) and converts them to Result.Error(DataError.Network.*) - HTTP status → typed error, and a malformed body → SERIALIZATION (the cause chain is unwrapped because Ktor wraps the kotlinx SerializationException). Upper layers never see raw exceptions.
  • The presentation layer maps a DataError to user-facing UiText via DataError.toUiText() (:core:presentation). UiText is itself Compose-free (a StringResource/DynamicString), so a UI-agnostic ViewModel can hold UiText? 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 NavController passed into charactersGraph(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.characterId into the destination's arguments, which Navigation copies into the ViewModel's SavedStateHandle. CharacterDetailViewModel reads savedStateHandle.get<Int>("characterId") by field name - so the UI-agnostic presentation module needs no navigation dependency. The same SavedStateHandle also 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()) } in coreDataModule (a factory call, not a constructor). Compose roots inject with koinViewModel(); the Fragment uses by viewModel() - both resolve the same CharacterListViewModel class and supply its SavedStateHandle.

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 CharacterRepository interface with MockK - coEvery scripts the suspend calls, coVerify asserts 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 + snackbar Event, pagination end-reached, process-death restore, and the rapid-duplicate-paging guard (which is why these use StandardTestDispatcher).
  • 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: CharacterListRobot methods return this so a test reads as a scenario; it asserts a rendered item, the empty/error states, and that a tap fires the right Action.

JUnit 5 on AGP 9: the de.mannodermaus.android-junit5 Gradle plugin targets AGP 8.x, so this repo doesn't use it. AndroidUnitTest extends Gradle's Test, so the architecture.android.unit.test convention plugin just calls useJUnitPlatform() and adds the unit-test bundle - including the junit-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 removed InputManager.getInstance(). The catalog pins espresso/runner to current versions in the compose-ui-test bundle 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.

Description
No description provided
Readme 466 KiB
Languages
Kotlin 100%