Mobile No. 330

Android Architecture skill for Claude Code

Layers, Hilt and modules that keep Android apps testable. Claude restructures Android apps along the official layered model: ViewModels expose one StateFlow UiState, repositories make Room the source of truth and keep DTOs internal, Hilt binds interfaces through KSP, and long work moves to WorkManager instead of GlobalScope. It splits modules with a strict dependency direction and convention plugins, and backs each layer with fake-based JVM tests.

  • nameandroid-architecture
  • size223 lines · ≈2,500 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Structures modern Android apps following the official layered architecture — UI layer with ViewModels and unidirectional data flow (MVVM or MVI), optional domain use cases, repositories as the single source of truth over Room and Retrofit or Ktor, Hilt dependency injection with KSP, coroutines and Flow with correct dispatchers and scopes, WorkManager, and Gradle modularization with version catalogs and convention plugins. Use when starting an Android app, untangling a god Activity or ViewModel, adding Hilt or Room, splitting into feature modules, or making the codebase testable with fakes.

Try asking

  • Refactor this 900-line Activity into ViewModel, repository and Room layers with Hilt
  • Split our single-module Android app into feature and core modules without breaking the build
  • Set up an offline-first repository so the order list works without network

Use it when

  • Starting a new Android app or feature and choosing its structure
  • A god Activity, Fragment, or ViewModel mixes UI, network, and database code
  • Adding Hilt, Room, or WorkManager to an existing codebase
  • Build times or ownership call for Gradle modularization

Skip it when

  • Writing Compose UI, side effects, or recomposition fixes — use jetpack-compose
  • Designing sync conflict resolution and outbox queues — use offline-sync

How to check the result

Builds, unit tests and lint pass; the module graph has no feature-to-feature or feature-to-database edges; and screens keep state across rotation and offline launches.

The common mistake it prevents

Adding a pass-through use case for every repository call and a giant core:common module, which adds layers without adding boundaries.

~/.claude/skills/android-architecture/SKILL.md

--- name: android-architecture description: Structures modern Android apps following the official layered architecture — UI layer with ViewModels and unidirectional data flow (MVVM or… license: MIT ---

Done well means: data flows one way (repository Flow, ViewModel StateFlow, UI), each layer depends only on the layer below through interfaces it owns, the local database is the source of truth the UI observes, dependencies come from Hilt rather than singletons, modules have one-directional dependencies, and every ViewModel and repository is tested with fakes on the JVM without an emulator.

Compose-specific UI patterns (state hoisting, side effects, recomposition) live in jetpack-compose; Kotlin language idioms live in kotlin-idioms.

Before changing anything

  • Read settings.gradle.kts, gradle/libs.versions.toml, and the module graph (./gradlew :app:dependencies --configuration releaseRuntimeClasspath or a module graph plugin). Note AGP, Kotlin, KSP versions and whether kapt is still in use.
  • Find where state lives: Activities/Fragments holding data, object singletons, static Context, GlobalScope. These are the first refactor targets.
  • Check minSdk, Compose vs. Views, existing DI (Hilt, Koin, manual), and network stack.
  • Run the existing tests and lint so a baseline exists before moving code.

Layers and dependency rule

:app ──► :feature:* ──► :core:domain (optional) ──► :core:data ──► :core:database, :core:network
                    └──► :core:designsystem, :core:ui          └──► :core:model (plain Kotlin)
LayerContainsMust not
UI (feature modules)Composables/Fragments, ViewModel, UiStateTouch DAO, Retrofit, or Context-heavy APIs directly
Domain (optional)Use cases combining repositories; pure KotlinDepend on Android framework
DataRepository interfaces + impls, data sources, mappersExpose DTOs or entities upward
ModelDomain data classes shared by allDepend on anything

Add a domain layer only when logic is reused by several ViewModels or is complex enough to test alone. A use case that just forwards one repository call is noise.

Workflow

- [ ] 1. Map current state holders and side effects; pick one feature to migrate first
- [ ] 2. Define the model classes and repository interface for that feature
- [ ] 3. Implement data sources (Room DAO with Flow, API client) and the repository
- [ ] 4. Hilt modules binding interfaces; @HiltViewModel exposing StateFlow<UiState>
- [ ] 5. UI collects state with lifecycle awareness and sends events as function calls
- [ ] 6. Background work via WorkManager (sync, uploads), not services or GlobalScope
- [ ] 7. Extract modules once boundaries are proven; add convention plugins
- [ ] 8. Tests: ViewModel + repository with fakes and runTest; DAO with in-memory Room

MVVM vs. MVI

PickWhen
MVVM with one UiState data class + public functions per eventDefault for most screens
MVI (sealed Action → reducer → UiState)Complex screens with many interacting events, undo, or time-travel debugging

Either way: one immutable state object per screen, exposed as StateFlow, never several independently mutable LiveDatas the UI must reconcile.

@HiltViewModel
class OrdersViewModel @Inject constructor(
    private val repo: OrdersRepository,
    savedState: SavedStateHandle,
) : ViewModel() {
    private val filter = savedState.getStateFlow("filter", OrderFilter.All)

    val uiState: StateFlow<OrdersUiState> = filter
        .flatMapLatest { repo.observeOrders(it) }
        .map<List<Order>, OrdersUiState> { OrdersUiState.Success(it) }
        .catch { emit(OrdersUiState.Error(it.message)) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), OrdersUiState.Loading)

    fun refresh() = viewModelScope.launch { repo.sync() }   // errors surface via state, not crash
}

WhileSubscribed(5_000) keeps the upstream alive through configuration changes but stops it 5 s after the UI leaves.

Data layer: offline-first repository

interface OrdersRepository {
    fun observeOrders(filter: OrderFilter): Flow<List<Order>>
    suspend fun sync()
}

internal class DefaultOrdersRepository @Inject constructor(
    private val dao: OrderDao,
    private val api: OrdersApi,
    @Dispatcher(IO) private val io: CoroutineDispatcher,
) : OrdersRepository {
    override fun observeOrders(filter: OrderFilter) =
        dao.observe(filter.statusOrNull).map { rows -> rows.map(OrderEntity::toModel) }

    override suspend fun sync() = withContext(io) {
        val remote = api.orders()                      // DTOs stay in this module
        dao.upsertAll(remote.map(OrderDto::toEntity))  // UI updates through the Flow
    }
}
  • The UI observes the database; the network only writes into it. This gives offline reads and one code path for “data changed”.
  • Room: @Upsert, Flow return types for observation, suspend for one-shot writes, @Transaction for multi-table writes. Export schemas (room { schemaDirectory(...) } with the Room Gradle plugin) and write Migrations; never ship fallbackToDestructiveMigration() for user data.
  • Main-safety: suspend functions in the data layer switch dispatchers themselves; callers never wrap them in withContext(Dispatchers.IO). Room and Retrofit suspend functions are already main-safe.
  • Inject dispatchers (qualifier annotation) so tests can pass a StandardTestDispatcher.
  • For full conflict handling and outbox writes, follow offline-sync.

Hilt

@HiltAndroidApp class AcmeApp : Application()

@Module @InstallIn(SingletonComponent::class)
internal abstract class DataModule {
    @Binds abstract fun ordersRepository(impl: DefaultOrdersRepository): OrdersRepository
}

@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides @Singleton
    fun db(@ApplicationContext ctx: Context): AcmeDatabase =
        Room.databaseBuilder(ctx, AcmeDatabase::class.java, "acme.db").build()
    @Provides fun orderDao(db: AcmeDatabase) = db.orderDao()
}
  • Use KSP for Hilt and Room; kapt is in maintenance mode and slows builds.
  • @Binds for interface-to-impl, @Provides for third-party builders.
  • Scope deliberately: @Singleton for DB, HTTP client, repositories with caches; no scope for cheap stateless classes.
  • Activities/Fragments need @AndroidEntryPoint; ViewModels @HiltViewModel; workers @HiltWorker with HiltWorkerFactory configured via Configuration.Provider.
  • Get Compose ViewModels with hiltViewModel() from the Hilt lifecycle-viewmodel-compose artifact (older projects use hilt-navigation-compose).

Coroutines and scopes

WorkScope
Tied to a screenviewModelScope
Must outlive the screen but not the process (finish a save)Injected @ApplicationScope CoroutineScope with SupervisorJob()
Must survive process death or need constraints (network, charging)WorkManager CoroutineWorker
NeverGlobalScope, runBlocking on the main thread

Expose Flow/StateFlow, not LiveData, in new code. Re-throw CancellationException in every catch.

Modularization

  • Start with :app, :core:*, and one :feature:*; extract more only when build times or team ownership justify it.
  • Feature modules never depend on each other; navigation between them goes through route contracts in a small :feature:x:api module or the app’s nav graph.
  • Repository impls are internal; only interfaces are public.
  • Convention plugins in an included build-logic build (AndroidLibraryConventionPlugin, AndroidFeatureConventionPlugin, HiltConventionPlugin) remove copy-pasted Gradle.
  • All versions live in gradle/libs.versions.toml; modules reference libs.room.runtime.
  • Enable the Gradle configuration cache and build cache in gradle.properties once plugins support it.

Testing

class OrdersViewModelTest {
    @get:Rule val main = MainDispatcherRule()          // sets Dispatchers.Main to a test dispatcher
    private val repo = FakeOrdersRepository()

    @Test fun showsOrdersFromRepository() = runTest {
        val vm = OrdersViewModel(repo, SavedStateHandle())
        vm.uiState.test {                              // Turbine
            assertEquals(OrdersUiState.Loading, awaitItem())
            repo.emit(listOf(order("1")))
            assertEquals(OrdersUiState.Success(listOf(order("1"))), awaitItem())
        }
    }
}
  • Fakes (in a :core:testing module) over mocking frameworks for repositories; they stay correct when the interface changes.
  • DAO tests: Room.inMemoryDatabaseBuilder with Robolectric or as instrumented tests.
  • Hilt instrumented tests: @HiltAndroidTest, HiltAndroidRule, @TestInstallIn to swap modules.
  • Architecture checks: Konsist or a lint rule that fails if :feature:* imports *.database.* or retrofit2.*.

Gotchas

  • Collecting Flows in onCreate with lifecycleScope.launch without repeatOnLifecycle(STARTED) keeps collecting in the background.
  • SharingStarted.Eagerly in ViewModels keeps database observers running for screens nobody sees.
  • Exposing MutableStateFlow publicly lets the UI mutate state; expose asStateFlow().
  • Passing Activity context into singletons leaks it; use @ApplicationContext.
  • Mapping entities to models on the main thread for large lists causes jank; map inside the Flow (Room emits on a background thread).
  • Huge :core:common modules recreate the monolith; split by responsibility.

Verification

  1. ./gradlew assembleDebug testDebugUnitTest lint passes.
  2. Module graph shows no feature-to-feature edges and no feature depending on database or network modules directly.
  3. Rotate the device and send the app to background: state survives, no duplicate network calls in logs, collection stops in background.
  4. Airplane mode: screens still show cached data and recover after sync().
  5. Every new ViewModel and repository has a JVM unit test using fakes and runTest.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc