DEV Community

Olivia Craft
Olivia Craft

Posted on

CLAUDE.md for Android and Jetpack Compose: 13 Rules That Make AI Write Modern, Production-Ready Android Code

CLAUDE.md for Android and Jetpack Compose: 13 Rules That Make AI Write Modern, Production-Ready Android Code

Android development has two eras of AI output quality.

The first era: AI that reaches for XML layouts, AsyncTask, SharedPreferences, and findViewById. Code that compiles on Android 14 but was written for Android 4. Code that passes lint but violates every modern architecture guideline.

The second era: AI that defaults to Compose, coroutines, ViewModel, StateFlow, Hilt, and the patterns from the Now in Android reference project.

The difference between these outcomes is a CLAUDE.md that specifies which Android you're building.

These 13 rules cover the patterns that matter most — the ones where AI drifts to legacy APIs without explicit instruction.


Rule 1: Jetpack Compose only — no XML layouts

UI: Jetpack Compose exclusively. No XML layouts, no View system.
Forbidden: ConstraintLayout XML, LinearLayout, RelativeLayout, findViewById.
Composables: stateless where possible. State hoisted to ViewModel.
Preview: @Preview annotation on every composable component.
Material3 only — not Material2.
Enter fullscreen mode Exit fullscreen mode

AI has seen enormous volumes of XML-based Android code. Without a rule, it will generate setContentView(R.layout.activity_main) and ViewBinding for new screens in a Compose project.

// Banned — XML/View system
class UserFragment : Fragment() {
    private lateinit var binding: FragmentUserBinding
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        binding.nameText.text = viewModel.userName
    }
}

// Required — Jetpack Compose
@Composable
fun UserScreen(
    uiState: UserUiState,
    onAction: (UserAction) -> Unit
) {
    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text(text = uiState.userName, style = MaterialTheme.typography.headlineMedium)
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: ViewModel + UiState — no logic in Composables

Architecture:
- ViewModel: holds UI state, handles user actions, calls use cases.
- UiState: sealed class or data class representing screen state.
- Composables: render UiState. No business logic. No direct repository calls.
- Events: one-time events (navigation, toasts) via Channel<UiEvent>.
- StateFlow<UiState> exposed from ViewModel. Collected with collectAsStateWithLifecycle().
Enter fullscreen mode Exit fullscreen mode

AI generates Composables that call repositories directly, fetch data in LaunchedEffect without a ViewModel, and hold mutable state locally for things that should survive rotation.

// Wrong — business logic and state in Composable
@Composable
fun UserScreen(repository: UserRepository) {
    var userName by remember { mutableStateOf("") }
    LaunchedEffect(Unit) {
        userName = repository.getUser().name  // Direct repo call, no ViewModel
    }
    Text(text = userName)
}

// Right — ViewModel owns state
@HiltViewModel
class UserViewModel @Inject constructor(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    init { loadUser() }

    private fun loadUser() = viewModelScope.launch {
        _uiState.value = getUserUseCase().fold(
            onSuccess = { UserUiState.Content(it) },
            onFailure = { UserUiState.Error(it.message) }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule 3: Coroutines and Flow — no callbacks, no RxJava

Async: Kotlin coroutines everywhere. No callbacks. No RxJava. No AsyncTask.
Repositories: return Flow<T> for streams, suspend fun for one-shot operations.
Dispatchers: IO for network/disk, Default for CPU-intensive. Never Main for background work.
viewModelScope: use for ViewModel coroutines. Never GlobalScope.
Cancellation: coroutines are cooperative — check cancellation in long loops.
Enter fullscreen mode Exit fullscreen mode

AI generates callback-style code and sometimes RxJava (from older training data). Both are banned in modern Android.

// Banned — callback style
fun getUser(id: String, callback: (User) -> Unit) {
    thread { callback(api.getUser(id)) }  // No cancellation, no error handling
}

// Required — coroutines
suspend fun getUser(id: String): Result<User> = withContext(Dispatchers.IO) {
    runCatching { api.getUser(id) }
}

// Repository stream
fun observeMessages(): Flow<List<Message>> = messageDao.observeAll()
    .flowOn(Dispatchers.IO)
    .catch { emit(emptyList()) }
Enter fullscreen mode Exit fullscreen mode

Rule 4: Hilt for dependency injection — no manual DI, no Koin

DI: Hilt exclusively.
Modules: @InstallIn with appropriate component scope.
ViewModel: @HiltViewModel + @Inject constructor.
Application: @HiltAndroidApp on Application class.
No: manual object graphs, service locators, companion object getInstance().
Enter fullscreen mode Exit fullscreen mode

AI generates singleton patterns and manual DI because they're simpler to write inline. Hilt makes dependencies explicit, scoped, and testable.

// Banned — manual singleton
object NetworkClient {
    val api: ApiService by lazy { Retrofit.Builder()... }
}

// Required — Hilt module
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideApiService(client: OkHttpClient): ApiService =
        Retrofit.Builder().client(client).baseUrl(BASE_URL).build()
            .create(ApiService::class.java)
}
Enter fullscreen mode Exit fullscreen mode

Rule 5: Repository pattern — data layer is abstract

Data layer:
- Repository interface in domain layer. Implementation in data layer.
- Repositories return domain models — never network DTOs or Room entities.
- Mapping: explicit mappers between layers (DTO → Domain, Entity → Domain).
- Local-first: Room as source of truth. Network syncs to Room. UI observes Room.
- No direct Retrofit calls from ViewModel or use cases.
Enter fullscreen mode Exit fullscreen mode
// Domain layer — interface
interface UserRepository {
    fun observeUser(id: String): Flow<User>
    suspend fun syncUser(id: String): Result<Unit>
}

// Data layer — implementation
class UserRepositoryImpl @Inject constructor(
    private val api: UserApi,
    private val dao: UserDao,
    private val mapper: UserMapper
) : UserRepository {
    override fun observeUser(id: String): Flow<User> =
        dao.observeById(id).map(mapper::toDomain)

    override suspend fun syncUser(id: String): Result<Unit> = runCatching {
        val dto = api.getUser(id)
        dao.upsert(mapper.toEntity(dto))
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule 6: Room for local persistence — no SharedPreferences for structured data

Local data:
- Room for all structured data. No raw SQLite.
- SharedPreferences / DataStore: only for simple key-value preferences (theme, onboarding flag).
- DataStore (Proto or Preferences): replace SharedPreferences for new code.
- Room DAOs: return Flow<T> for observed queries, suspend fun for mutations.
- Migrations: explicit RoomDatabase.Migration — never destructive migration in production.
Enter fullscreen mode Exit fullscreen mode
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :id")
    fun observeById(id: String): Flow<UserEntity?>

    @Upsert
    suspend fun upsert(user: UserEntity)

    @Query("DELETE FROM users WHERE id = :id")
    suspend fun deleteById(id: String)
}
Enter fullscreen mode Exit fullscreen mode

Rule 7: Navigation — Jetpack Navigation Compose with typed routes

Navigation:
- Jetpack Navigation Compose with type-safe routes (Navigation 2.8+).
- Routes: sealed class or @Serializable data class — no string literals.
- Single NavHost per feature graph. Nested graphs for feature modules.
- Navigate from ViewModel via UiEvent channel — not directly from Composable.
- Deep links: declared in NavGraph, not hardcoded in AndroidManifest.
Enter fullscreen mode Exit fullscreen mode

Rule 8: State management — immutable UiState, unidirectional data flow

State rules:
- UiState: immutable data class. Use copy() for updates.
- MutableStateFlow inside ViewModel, exposed as StateFlow (read-only).
- No mutableStateOf in ViewModel — use MutableStateFlow.
- Composables: collect with collectAsStateWithLifecycle() (lifecycle-aware).
- No shared mutable state between ViewModels.
Enter fullscreen mode Exit fullscreen mode
data class SearchUiState(
    val query: String = "",
    val results: List<SearchResult> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

// In ViewModel
_uiState.update { it.copy(isLoading = true) }
Enter fullscreen mode Exit fullscreen mode

Rule 9: Testing — ViewModel unit tests, Composable screenshot tests

Testing:
- ViewModel: JUnit 5 + MockK + Turbine (for Flow testing). No Android dependencies.
- Repository: JUnit 5 + MockK for unit tests. Room in-memory DB for integration.
- Composables: Compose UI test (composeTestRule) or Paparazzi for screenshot tests.
- No Robolectric for new tests — prefer pure JVM or instrumented tests.
- coroutinesTestRule: StandardTestDispatcher for deterministic coroutine tests.
Enter fullscreen mode Exit fullscreen mode
@Test
fun `search query updates state`() = runTest {
    val viewModel = SearchViewModel(mockRepository)
    viewModel.uiState.test {
        assertThat(awaitItem().query).isEmpty()
        viewModel.onQueryChanged("kotlin")
        assertThat(awaitItem().query).isEqualTo("kotlin")
    }
}
Enter fullscreen mode Exit fullscreen mode

Rule 10: Permissions — request at point of use, handle denial gracefully

Permissions:
- Request via rememberLauncherForActivityResult — never startActivityForResult.
- Check permission before use: ContextCompat.checkSelfPermission.
- Handle denial: show rationale UI, offer settings redirect after permanent denial.
- Never crash or silently fail on permission denial.
- Dangerous permissions: request only when the user triggers the action.
Enter fullscreen mode Exit fullscreen mode

Rule 11: API communication — Retrofit + OkHttp + kotlinx.serialization

Network:
- Retrofit for REST. OkHttp with logging interceptor (debug builds only).
- Serialization: kotlinx.serialization. No Gson, no Moshi.
- Response wrapper: Result<T> or sealed class — never nullable responses.
- Timeouts: explicit connect (10s), read (30s), write (30s) timeouts.
- Certificate pinning for production builds.
Enter fullscreen mode Exit fullscreen mode

Rule 12: Build config — Gradle Kotlin DSL, version catalog

Build:
- Gradle Kotlin DSL (.kts) only — no Groovy.
- Version catalog (libs.versions.toml) for all dependencies.
- Build variants: debug (logging, mock data) / release (ProGuard, no logging).
- No hardcoded API keys or secrets in BuildConfig — use local.properties + secrets-gradle-plugin.
- Baseline profiles for startup performance.
Enter fullscreen mode Exit fullscreen mode

Rule 13: The CLAUDE.md block for Android

## Android Standards

**Min SDK:** 26 | **Target SDK:** 35 | **Language:** Kotlin 2.x
**UI:** Jetpack Compose + Material3 only (no XML layouts)
**Architecture:** MVVM + Clean Architecture (presentation / domain / data)

### UI Layer
- Composables: stateless. State hoisted to ViewModel.
- UiState: immutable data class, exposed as StateFlow<UiState>
- Collect with collectAsStateWithLifecycle() — not collectAsState()
- One-time events: Channel<UiEvent> in ViewModel

### Data Layer
- Repository pattern: interface in domain, impl in data
- Room as source of truth. Network syncs to Room.
- Coroutines: suspend fun for one-shot, Flow for streams
- No callbacks, no RxJava, no AsyncTask

### DI
- Hilt exclusively. @HiltViewModel on all ViewModels.
- No manual DI, no Koin, no service locators.

### Testing
- ViewModel: JUnit 5 + MockK + Turbine
- No Robolectric. Pure JVM unit tests preferred.

### Build
- Gradle Kotlin DSL + version catalog (libs.versions.toml)
- No secrets in source code or BuildConfig
Enter fullscreen mode Exit fullscreen mode

Why Android specifically needs this

Android's API surface has been evolving continuously for 15 years. AI models have absorbed code from every era — from AsyncTask through RxJava to coroutines, from XML inflation through DataBinding to Compose.

Without explicit rules, the model defaults to the mean of that training distribution. That mean includes a lot of code that works on current Android but violates current best practices, creates maintenance debt, and will break when APIs are deprecated.

The rules above aren't a style guide. They're the line between code that a senior Android engineer would merge and code that gets sent back for a full rewrite.

The CLAUDE.md block at the end is what you add to your repo. The AI reads it at the start of every session, and every Composable, ViewModel, and repository it generates starts from those constraints rather than from the average of all Android code ever written.

Top comments (0)