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.
- name
android-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.
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 releaseRuntimeClasspathor a module graph plugin). Note AGP, Kotlin, KSP versions and whether kapt is still in use. - Find where state lives: Activities/Fragments holding data,
objectsingletons, staticContext,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)
| Layer | Contains | Must not |
|---|---|---|
| UI (feature modules) | Composables/Fragments, ViewModel, UiState | Touch DAO, Retrofit, or Context-heavy APIs directly |
| Domain (optional) | Use cases combining repositories; pure Kotlin | Depend on Android framework |
| Data | Repository interfaces + impls, data sources, mappers | Expose DTOs or entities upward |
| Model | Domain data classes shared by all | Depend 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
| Pick | When |
|---|---|
MVVM with one UiState data class + public functions per event | Default 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,Flowreturn types for observation,suspendfor one-shot writes,@Transactionfor multi-table writes. Export schemas (room { schemaDirectory(...) }with the Room Gradle plugin) and writeMigrations; never shipfallbackToDestructiveMigration()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.
@Bindsfor interface-to-impl,@Providesfor third-party builders.- Scope deliberately:
@Singletonfor DB, HTTP client, repositories with caches; no scope for cheap stateless classes. - Activities/Fragments need
@AndroidEntryPoint; ViewModels@HiltViewModel; workers@HiltWorkerwithHiltWorkerFactoryconfigured viaConfiguration.Provider. - Get Compose ViewModels with
hiltViewModel()from the Hilt lifecycle-viewmodel-compose artifact (older projects usehilt-navigation-compose).
Coroutines and scopes
| Work | Scope |
|---|---|
| Tied to a screen | viewModelScope |
| 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 |
| Never | GlobalScope, 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:apimodule or the app’s nav graph. - Repository impls are
internal; only interfaces are public. - Convention plugins in an included
build-logicbuild (AndroidLibraryConventionPlugin,AndroidFeatureConventionPlugin,HiltConventionPlugin) remove copy-pasted Gradle. - All versions live in
gradle/libs.versions.toml; modules referencelibs.room.runtime. - Enable the Gradle configuration cache and build cache in
gradle.propertiesonce 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:testingmodule) over mocking frameworks for repositories; they stay correct when the interface changes. - DAO tests:
Room.inMemoryDatabaseBuilderwith Robolectric or as instrumented tests. - Hilt instrumented tests:
@HiltAndroidTest,HiltAndroidRule,@TestInstallInto swap modules. - Architecture checks: Konsist or a lint rule that fails if
:feature:*imports*.database.*orretrofit2.*.
Gotchas
- Collecting Flows in
onCreatewithlifecycleScope.launchwithoutrepeatOnLifecycle(STARTED)keeps collecting in the background. SharingStarted.Eagerlyin ViewModels keeps database observers running for screens nobody sees.- Exposing
MutableStateFlowpublicly lets the UI mutate state; exposeasStateFlow(). - Passing
Activitycontext 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:commonmodules recreate the monolith; split by responsibility.
Verification
./gradlew assembleDebug testDebugUnitTest lintpasses.- Module graph shows no feature-to-feature edges and no feature depending on database or network modules directly.
- Rotate the device and send the app to background: state survives, no duplicate network calls in logs, collection stops in background.
- Airplane mode: screens still show cached data and recover after
sync(). - 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.