Android Architecture Interview Questions
Architecture questions for mid-level and senior Android roles: MVVM, MVI and MVP, Clean Architecture layers and use cases, repositories, multi-module projects and navigation, testing strategy, and how data flows from the network to the UI.
By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked
All 50 questions
- What is MVVM? Explain each layer and its responsibility. Most asked
- What is Clean Architecture? How does it differ from plain MVVM? Most asked
- What is MVI architecture? How does it differ from MVVM? Most asked
- What is the Repository pattern and why is it important? Most asked
- What is Dependency Injection? Explain Hilt and its key annotations. Most asked
- What is multi-module architecture? What are the benefits and trade-offs? Most asked
- What are the SOLID principles? Give Android examples for each. Most asked
- How do you model UiState? What is the difference between a sealed class and a data class approach? Most asked
- How do you handle navigation in a multi-module app?
- Scenario: You're building a new e-commerce app for a 5-person team. What architecture would you choose and why?
- What is the difference between @Binds and @Provides in Hilt? Most asked
- What is the domain layer? What should and shouldn't go in it?
- What is the difference between MVVM and MVP? Why did MVVM win in Android? Most asked
- How do you test a ViewModel? What are best practices? Most asked
- Scenario: A PR shows the ViewModel creating Retrofit directly and updating a TextView. What's wrong and how do you fix it?
- Scenario: Your app has a 3-minute build time. How do you reduce it with modularization?
- What are common Android design patterns? Explain Observer, Factory, Builder, and Strategy. Most asked
- What is the data layer? How do you map between DTOs and domain entities?
- Scenario: Your app needs to support offline mode. How do you architect this?
- What is Version Catalog (libs.versions.toml)? Why is it essential in multi-module projects? Most asked
- What is the difference between Unit tests, Integration tests, and UI tests? Most asked
- What is Koin vs Hilt for DI? When would you choose each?
- Scenario: Your ViewModel is 500+ lines. How do you refactor it?
- Scenario: Your team debates MVVM vs MVI for a complex order tracking screen with real-time WebSocket updates, multiple data sources, and a cancel button. Which do you choose?
- Scenario: Explain how data flows from server to UI in your Clean Architecture app — as if onboarding a junior developer.
- What are Hilt scopes and how do they affect dependency lifetime? When does a @Singleton leak memory?
- Scenario: Your team needs to add analytics tracking to every user action across 20 screens — without touching every screen.
- What is the Result type in Kotlin? How does it improve error handling compared to throwing exceptions? Most asked
- Scenario: Two feature modules need to share user session data. How do you do this without creating a circular dependency?
- What are convention plugins (build-logic)? How do they reduce Gradle duplication in multi-module projects?
- What is SavedStateHandle? How does it differ from ViewModel state survival? Most asked
- Scenario: Implement feature flags to gradually roll out a new checkout flow. How do you architect this cleanly?
- What is the difference between api() and implementation() in Gradle? Why does it matter for multi-module build speed? Most asked
- Scenario: A payment feature must be built by a separate team over 3 months without blocking the main app. How do you architect this?
- What is the Now in Android (NiA) architecture? What patterns can you adopt from it? Most asked
- Scenario: Your app is growing. When do you migrate from single-module to multi-module, and what's the migration strategy?
- What is Dependency Inversion vs Dependency Injection — and how do they work together?
- How do you handle one-time UI events (navigation, snackbar, dialog) in MVVM and MVI? Most asked
- Scenario: Your app has multiple flavors (free, pro, enterprise). How do you architect this with Hilt and multi-module?
- Scenario: Your app suddenly has to support a new backend API while keeping the old one running for 3 months. How do you architect this transition?
- What is a use case (interactor) — when should you use them and when are they overkill? Most asked
- Scenario: During a code review you find a junior dev using LiveData in the domain layer and Room @Entity in the domain model. What's wrong and what do you say?
- What is the difference between application module, library module, and dynamic feature module? Most asked
- Scenario: Your team is debating whether to use MVI or MVVM for ALL screens. What's your recommendation?
- Scenario: A new team member asks "why can't I just put everything in the Activity?" — how do you explain the architecture decisions made so far?
- What is Qualifier in Hilt (@Named vs custom @Qualifier)? When do you need them? Most asked
- What is the difference between monorepo single-module and multi-module? When is each appropriate?
- Scenario: Your app processes user data. A legal requirement arrives — all user data must be encrypted at rest and PII must be deletable. How do you architect this?
- What are the key architectural improvements in Android 2024-25 — KSP, Compose Navigation 2.8, and Hilt updates?
- Scenario: You're leading a 6-month architecture review for a 3-year-old Android app with 200K LOC. What's your process?
1. What is MVVM? Explain each layer and its responsibility.
MVVM (Model-View-ViewModel) separates UI from business logic. Each layer has a single, clear responsibility — making the code testable, maintainable, and rotation-safe.
// MODEL — data and business logic data class User(val id: String, val name: String) class UserRepository @Inject constructor( private val api: UserApi, private val db: UserDao ) { suspend fun getUser(id: String): User = db.get(id) ?: api.fetchUser(id).also { db.insert(it) } } // VIEWMODEL — exposes state, handles user intent // Knows nothing about the View @HiltViewModel class UserViewModel @Inject constructor( private val repo: UserRepository ) : ViewModel() { private val _state = MutableStateFlow<UiState<User>>(UiState.Loading) val state = _state.asStateFlow() fun loadUser(id: String) { viewModelScope.launch { runCatching { repo.getUser(id) } .onSuccess { _state.value = UiState.Success (it) } .onFailure { _state.value = UiState.Error (it.message !!) } } } } // VIEW — observes state, sends intents @Composable fun UserScreen(vm: UserViewModel = hiltViewModel()) { val state by vm.state.collectAsStateWithLifecycle() when (state) { is UiState.Loading -> Spinner() is UiState.Success -> UserCard(state.data) is UiState.Error -> ErrorView(state.msg) } }
The Model layer — contains everything related to data: data classes that model your business domain, a Repository that abstracts all data sources (Room database, Retrofit API, SharedPreferences, in-memory cache), and the business rules that determine how data is fetched, cached, or combined. The ViewModel never knows whether data came from network or disk — it just calls the Repository and receives a result. This separation means you can swap a real network API for a fake implementation in tests without changing a single line of ViewModel code.
The ViewModel layer — acts as the bridge between the Model and the View. It holds and manages UI state (exposed as StateFlow), calls the Repository to fetch or write data, and translates raw data into UI-ready models. The ViewModel survives configuration changes (screen rotation, theme changes) because it's retained by ViewModelStore — separate from the Activity/Fragment lifecycle. The golden rule: the ViewModel must never hold a reference to an Activity, Fragment, View, or Context. Holding any of these prevents garbage collection after rotation, causing memory leaks.
The View layer — the Activity, Fragment, or Composable. Its only job is to render the state exposed by the ViewModel and forward user actions back. Zero business logic lives here. In Compose, this is natural: your Composables are functions of state and emit events (lambdas) upward. In the View system, you observe StateFlow with repeatOnLifecycle(STARTED) to update UI elements. The View never accesses the Model directly — all data flows through the ViewModel — which is what makes the screen independently testable and rotation-safe.
"ViewModel knows nothing about the View" is the most important principle. If your ViewModel imports android.widget.TextView or stores a Context, it's wrong. ViewModel exposes state — View renders it. That's the complete contract.
2. What is Clean Architecture? How does it differ from plain MVVM?
Clean Architecture adds a Domain layer with use cases between ViewModel and Repository, enforcing strict dependency rules. Inner layers know nothing about outer layers — Domain is pure Kotlin.
// Clean Architecture layers: Presentation → Domain ← Data // (Domain knows nothing about Presentation or Data) // DOMAIN LAYER — pure Kotlin, no Android, no frameworks data class User(val id: String, val name: String) interface UserRepository { // interface in domain suspend fun getUser(id: String): User } class GetUserUseCase @Inject constructor( private val repo: UserRepository // depends on interface ) { suspend operator fun invoke(id: String): User { require(id.isNotBlank ()) { "ID cannot be blank" } return repo.getUser(id) } } // DATA LAYER — implements domain interfaces class UserRepositoryImpl @Inject constructor( private val api: UserApi, private val dao: UserDao ) : UserRepository { override suspend fun getUser(id: String): User = dao.get(id)?.toDomain () ?: api.getUser(id).toDomain () } // PRESENTATION LAYER — calls use cases, not repositories class UserViewModel @Inject constructor( private val getUser: GetUserUseCase // not repository! ) : ViewModel() { fun load(id: String) { viewModelScope.launch { _state.value = UiState.Success (getUser(id)) } } } // Plain MVVM: ViewModel → Repository // Clean MVVM: ViewModel → UseCase → Repository Interface ← RepositoryImpl
What Clean Architecture adds over plain MVVM — plain MVVM has two concerns: presentation (ViewModel + View) and data (Model + Repository). Clean Architecture inserts a third layer between them — the Domain layer — containing use cases (also called interactors) and repository interfaces. The dependency rule is strict: outer layers import inner layers, never the reverse. Data and Presentation both import Domain. Domain imports nothing outside itself — no Android framework, no Retrofit, no Room. It's pure Kotlin.
Use cases — single responsibility for business logic — each use case represents one business operation: GetUserUseCase, PlaceOrderUseCase, ValidatePaymentUseCase. They live in the domain layer and coordinate between repository interfaces. A ViewModel that handles checkout might call three use cases — validation, order placement, and analytics tracking — keeping each testable in isolation. Use cases are also reusable: GetUserUseCase can be called from a profile screen ViewModel, a notification handler, and a widget — all without duplicating logic.
Domain purity and testability — the most important property of the domain layer is that it contains zero Android framework imports. No Context, no @Entity, no LiveData. Domain entities are plain Kotlin data classes. Repository interfaces are plain Kotlin interfaces. This means every use case and entity can be tested with a plain JVM unit test — no emulator, no Robolectric, no slow startup. Tests run in milliseconds. The architecture's value is most visible in testing: swap in a FakeUserRepository that implements the domain interface, and you have a fully controllable test environment for every business rule.
Use cases are justified when: (1) business logic is complex, (2) multiple ViewModels share the same logic, or (3) you need pure-Kotlin testability. For simple CRUD apps they can be over-engineering — saying this shows pragmatic thinking.
3. What is MVI architecture? How does it differ from MVVM?
MVI (Model-View-Intent) enforces strict unidirectional data flow with a single immutable state object. Every user action is an Intent that goes through a reducer — fully predictable and consistent.
// MVI: Intent → ViewModel (reducer) → State → View // Intent — sealed class of ALL user actions sealed class UserIntent { object Load : UserIntent() object Refresh : UserIntent() data class Search(val query: String) : UserIntent() } // State — ONE immutable data class for entire screen data class UserState( val isLoading: Boolean = false, val users: List<User> = emptyList(), val error: String? = null, val query: String = "" ) // ViewModel — dispatch() receives intents, updates single state @HiltViewModel class UserMviViewModel @Inject constructor( private val repo: UserRepository ) : ViewModel() { private val _state = MutableStateFlow(UserState()) val state = _state.asStateFlow() fun dispatch(intent: UserIntent) { viewModelScope.launch { when (intent) { is UserIntent.Load -> loadUsers() is UserIntent.Refresh -> { _state.update { it.copy (isLoading = true) }; loadUsers() } is UserIntent.Search -> _state.update { it.copy (query = intent.query) } } } } private suspend fun loadUsers() { _state.update { it.copy (isLoading = true, error = null) } runCatching { repo.getUsers() } .onSuccess { _state.update { s -> s.copy (isLoading = false, users = it) } } .onFailure { _state.update { s -> s.copy (isLoading = false, error = it.message ) } } } } // View — only calls dispatch() Button(onClick = { vm.dispatch(UserIntent.Refresh) }) { Text("Refresh") }
Unidirectional data flow — MVI enforces a strict cycle: the View emits Intents (user actions as sealed class events), the ViewModel processes them through a reduce function, produces a new immutable State, and exposes it back to the View. Data flows in one direction only — no two-way binding, no direct ViewModel calls from View that return values. This makes the entire screen's behaviour predictable: given any sequence of intents and the initial state, you can calculate the exact final state on paper. That determinism is what makes MVI screens dramatically easier to test and debug.
Single state object — the key difference from MVVM — in MVVM, you typically have multiple separate StateFlows: val isLoading: StateFlow<Boolean>, val users: StateFlow<List<User>>, val error: StateFlow<String?>. A bug can leave isLoading = true and error \!= null simultaneously — an inconsistent, impossible UI state. MVI uses one data class: data class UiState(val isLoading: Boolean, val users: List<User>, val error: String?). State transitions use _state.update { it.copy(isLoading = false, users = result) } — atomic, consistent, impossible to have contradictory combinations.
When to choose MVI over MVVM — MVI shines for complex screens where many events happen simultaneously: real-time WebSocket updates, user actions, pull-to-refresh, and background polling all hitting the screen at once. The single state object and unidirectional flow prevent race conditions between concurrent state updates. For simple screens — a settings page, a profile viewer — MVI's boilerplate (sealed intent classes, reduce function, single state object) isn't worth the overhead. MVVM is lighter and perfectly sufficient. Choose MVI when your screen has 3+ independent event sources or when you've had hard-to-reproduce state inconsistency bugs.
MVI's key advantage: impossible state combinations. With separate MVVM StateFlows you can accidentally emit isLoading=true AND hasError=true simultaneously. With MVI's single data class, state is always self-consistent. This is the architectural argument for MVI on complex screens.
4. What is the Repository pattern and why is it important?
The Repository is the single source of truth for data. It abstracts all data sources (network, database, cache) from the ViewModel — the ViewModel never knows where data comes from.
// Repository — single source of truth class ProductRepository @Inject constructor( private val api: ProductApi, private val dao: ProductDao ) { // Offline-first: DB is source of truth fun getProducts(): Flow<List<Product>> = dao.observeAll() suspend fun refresh() { val fresh = withContext(Dispatchers.IO) { api.getProducts() } dao.insertAll(fresh) // triggers getProducts() Flow } // Cache-first with network fallback suspend fun getProduct(id: String): Product = dao.get(id) ?: api.getProduct(id).also { dao.insert(it) } } // ViewModel — agnostic about data source class ProductViewModel @Inject constructor( private val repo: ProductRepository ) : ViewModel() { val products = repo.getProducts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) fun refresh() { viewModelScope.launch { repo.refresh() } } // No idea if data came from DB, API, or cache — doesn't need to know }
What the Repository does — the Repository is the single point of contact for all data operations. The ViewModel calls userRepository.getUser(id) and never knows whether that data comes from a Room database, a Retrofit API call, SharedPreferences, or an in-memory cache. The Repository owns that decision — and can change it without the ViewModel noticing. This abstraction is why Repository is one of the most universally adopted patterns in Android architecture: it cleanly separates "what data we need" (ViewModel) from "how we get it" (Repository + data sources).
Single source of truth — the Repository is responsible for keeping data consistent across sources. The recommended pattern is Room-as-truth: the Repository returns a Flow from the Room DAO (which auto-updates on any table change), and when a network call completes, it writes the result to Room rather than directly to the UI. The Room Flow then emits the new data automatically. This means the UI always reads from one source — the database — and the network merely refreshes that source. Offline mode works for free: if the network is unavailable, Room still has the last-known data.
Testability through fake implementations — the most important architectural benefit of the Repository pattern appears in testing. Define the Repository as an interface in the domain layer: interface UserRepository { suspend fun getUser(id: String): User }. In production, inject UserRepositoryImpl. In tests, inject FakeUserRepository that returns hardcoded data, throws errors on command, or simulates network delays. Your ViewModel tests become pure logic tests — no network, no database, no threading — running in milliseconds with complete control over every data scenario.
Single source of truth is the most important concept. Without it: DB shows 5 items, UI cache shows 3, next API call shows 8 — all inconsistent. Repository ensures everyone reads from the same place — the database — which is always the authoritative copy.
5. What is Dependency Injection? Explain Hilt and its key annotations.
Dependency Injection provides objects their dependencies from the outside instead of letting them create their own. Hilt is Google's recommended DI framework for Android — compile-time safe, built on Dagger.
// Without DI — tightly coupled, impossible to test class UserViewModel { private val repo = UserRepositoryImpl(RetrofitApi(), RoomDatabase()) // Can't swap to FakeRepository in tests } // Hilt setup @HiltAndroidApp class MyApp : Application() // Step 1: annotate Application @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton // Step 2: provide dependencies fun provideRetrofit(): Retrofit = Retrofit.Builder() .baseUrl("https://api.example.com").build () @Provides @Singleton fun provideApi(retrofit: Retrofit): UserApi = retrofit.create (UserApi::class.java) } // Step 3: inject via constructor class UserRepository @Inject constructor( private val api: UserApi, private val dao: UserDao ) @HiltViewModel class UserViewModel @Inject constructor( private val repo: UserRepository // Hilt auto-injects ) : ViewModel() // Key Hilt annotations: // @HiltAndroidApp — Application class // @AndroidEntryPoint — Activity, Fragment, Service // @HiltViewModel — ViewModel // @Inject — constructor / field injection // @Module — class that provides dependencies // @InstallIn — scope (SingletonComponent, ViewModelComponent...) // @Provides — method that creates a dependency // @Binds — bind interface to implementation // @Singleton — one instance app-wide
What Dependency Injection is — instead of a class creating its own dependencies (val api = Retrofit.create(...) inside a ViewModel), DI means the dependencies are provided from outside. The class declares what it needs in its constructor, and a framework (or manual wiring) provides them. This makes classes testable — you can inject a fake implementation in tests. It also makes dependencies explicit — you can see exactly what a class needs by reading its constructor. And it naturally enforces the Single Responsibility principle: classes that receive dependencies can't be responsible for creating them.
Hilt — compile-time safety — Hilt (built on Dagger) generates all the dependency graph wiring at compile time. If you forget to provide a binding — say, you add a constructor parameter to a ViewModel but don't tell Hilt how to provide it — the build fails with a clear error message. Nothing crashes at runtime. This is in contrast to runtime DI frameworks (like Koin) where missing dependencies only surface when the code runs. For production apps, the compile-time guarantee eliminates an entire category of crashes. Hilt integrates directly with @HiltViewModel, @AndroidEntryPoint, and Jetpack lifecycle components.
Scopes and annotations — Hilt scopes control how long a provided dependency lives. @Singleton (installed in SingletonComponent) creates one instance for the entire app lifetime — use for Retrofit, OkHttp, Room database. @ViewModelScoped creates one instance per ViewModel — correct for use cases and state holders tied to a single screen. @ActivityScoped and @FragmentScoped create instances tied to their respective lifecycle. The most common mistake is using @Singleton for a class that holds an Activity reference — the Activity can never be garbage collected, causing a memory leak. Match scope to the shortest lifecycle that's correct for the dependency.
The #1 DI benefit: testability. "With Hilt I create UserViewModel(FakeUserRepository()) in unit tests. Without DI, the real Retrofit is baked in — I can't test without the network." Interviewers want this specific answer, not a definition of DI.
6. What is multi-module architecture? What are the benefits and trade-offs?
Multi-module architecture splits the app into independent Gradle modules -- each with its own source set, dependencies, and build config. Each module compiles independently so a change in :feature:home doesn't force :feature:profile to recompile. The main trade-offs are faster incremental builds and enforced layer separation, versus the initial setup cost and increased Gradle complexity.
// Typical module graph (dependencies flow downward only) // :app → :feature:home → :core:domain → :core:data → :core:network // settings.gradle.kts -- declare all modules include(":app", ":feature:home", ":feature:profile", ":core:domain", ":core:data", ":core:network", ":core:ui") // :feature:home/build.gradle.kts -- depends only on core layers dependencies { implementation(project(":core:domain")) implementation(project(":core:ui")) // cannot depend on :feature:profile -- enforced by module boundaries }
The core benefit — incremental builds — in a single-module app, changing one file can trigger recompilation of the entire codebase. In a multi-module app, Gradle only recompiles the module that changed plus its direct dependents. If you change a feature module, the core modules are unaffected and served from the build cache instantly. With 10 feature modules and 5 core modules, a change in :feature:profile only recompiles that module and :app — not :feature:home, :feature:feed, or any :core:* modules. Build time for incremental changes drops from minutes to seconds.
Enforced boundaries and parallel compilation — modules make architectural boundaries physical. :feature:home cannot import :feature:profile because they're not in each other's dependency graph — the compiler enforces it. This prevents the gradual accumulation of accidental coupling that eventually makes a codebase unmaintainable. With org.gradle.parallel=true, independent modules compile simultaneously across CPU cores. A 15-module project can compile in roughly the time of its longest critical path — not the sum of all module times. This parallel build dividend grows as the project grows.
When to modularize — the honest trade-off — multi-module setup has real costs: writing and maintaining convention plugins, configuring each module's Gradle file, dealing with Hilt multi-module wiring, and the learning curve for new team members. For a 2-person team or a project under 50k lines of code, the overhead often exceeds the benefit. The pragmatic advice: start single-module with clean package structure (data/domain/presentation/di). Extract :core:network and :core:ui when build time consistently exceeds 2 minutes. Add feature modules when the team grows beyond 4-5 people or when you need Play Feature Delivery for on-demand download.
Frame the decision around team size and build pain. Solo developer on a small app → single module. 5+ devs, 3+ minute builds → multi-module. "I'd start with good package-by-feature structure in a single module and extract to multi-module when builds exceed 2 minutes."
7. What are the SOLID principles? Give Android examples for each.
SOLID principles guide object-oriented design toward maintainable, extensible, testable code. They're the foundation of all modern Android architecture patterns.
// S — Single Responsibility: one class, one reason to change // ❌ ViewModel fetches, formats dates, AND validates // ✅ class UserViewModel(private val repo: UserRepository) : ViewModel() { } class DateFormatter @Inject constructor() { fun format(ts: Long): String = /* ... */ "" } // O — Open/Closed: open for extension, closed for modification interface PaymentProcessor { suspend fun process(amount: Double): Result<Unit> } class StripeProcessor : PaymentProcessor { /* ... */ } class RazorpayProcessor : PaymentProcessor { /* ... */ } // Add new provider = new class, no existing code changed // L — Liskov Substitution: subtypes replaceable for base type fun checkout(processor: PaymentProcessor) { processor.process (100.0) // works with Stripe OR Razorpay } // I — Interface Segregation: don't force unused method impl // ❌ Fat interface interface UserManager { fun getUser(); fun saveUser(); fun banUser() } // ✅ Segregated interface UserReader { fun getUser(): User } interface UserAdmin { fun banUser(id: String) } // D — Dependency Inversion: depend on abstractions // ❌ class UserViewModel(private val repo: UserRepositoryImpl) // concrete! // ✅ class UserViewModel(private val repo: UserRepository) // interface
Single Responsibility and Open/Closed — Single Responsibility means a class has one reason to change. A ViewModel that manages state, formats dates, validates inputs, and sends analytics events has four reasons to change — a sign it needs splitting. Extract a DateFormatter, an InputValidator, and an AnalyticsTracker, each independently testable. Open/Closed means classes should be open for extension but closed for modification. Adding a new payment provider shouldn't require editing the existing PaymentProcessor — it should mean adding a new class that implements the interface. The existing code is untouched, untested code paths aren't disturbed, and the new implementation is independently reviewable.
Liskov Substitution and Interface Segregation — Liskov Substitution means any implementation of an interface must be usable wherever the interface is expected, without the caller needing to know which implementation it has. If CachedUserRepository implements UserRepository but throws UnsupportedOperationException from writeUser(), it violates LSP — it can't truly substitute the base contract. Interface Segregation means don't force classes to depend on methods they don't use. A ProfileViewModel that only reads user data shouldn't depend on a fat UserManager interface that also includes admin functions, bulk exports, and audit logging. Split it: UserReader, UserWriter, UserAdmin — each interface has only what its consumer needs.
Dependency Inversion — the most important for Android — high-level modules should depend on abstractions, not concrete implementations. The ViewModel should depend on UserRepository (interface in the domain layer) not on UserRepositoryImpl (class in the data layer). This single principle enables everything else: swapping implementations for testing, switching from Retrofit to Ktor without touching the ViewModel, providing different implementations based on build variant. In practice: define interfaces in the domain layer, implement them in the data layer, and wire them together in Hilt modules. Your ViewModel's constructor takes the interface — it never imports from the data package.
D (Dependency Inversion) is the most impactful for Android. ViewModel depending on a UserRepository interface — not UserRepositoryImpl — is what makes unit testing possible. This is why Hilt @Binds exists: to wire the interface to its implementation at runtime.
8. How do you model UiState? What is the difference between a sealed class and a data class approach?
UiState represents everything the screen needs to render. Two approaches — sealed class for mutually exclusive states, data class for combinable states. Each suits different screen complexity.
// Approach 1: Sealed class — mutually exclusive states sealed class UiState<out T> { object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val msg: String) : UiState<Nothing>() } // Pros: exhaustive when{}, impossible to be in two states // Cons: can't show data + refreshing spinner simultaneously // Approach 2: Data class — combinable states data class UserListState( val users: List<User> = emptyList(), val isLoading: Boolean = false, val isRefreshing: Boolean = false, // OLD data visible + spinner val error: String? = null ) // Pros: pull-to-refresh (users visible + isRefreshing=true) // Cons: possible invalid combinations // Update immutably _state.update { it.copy (isRefreshing = true) } val fresh = repo.refresh () _state.update { it.copy (users = fresh, isRefreshing = false) } // Hybrid — best of both worlds data class FeedState( val content: ContentState = ContentState.Loading, val isRefreshing: Boolean = false ) sealed class ContentState { object Loading; data class Success(val data: List<Item>); data class Error(val msg: String) }
Sealed class UiState — mutually exclusive states — sealed class UiState { object Loading : UiState(); data class Success(val users: List<User>) : UiState(); data class Error(val message: String) : UiState() }. This approach makes states mutually exclusive — the screen is either Loading, or Success, or Error; never two at once. Kotlin's when expression on a sealed class is exhaustive: the compiler forces you to handle every state. This is great for initial data loading where a blank loading screen transitioning to content is the correct UX, and partial states don't need to be shown simultaneously.
Data class UiState — combinable state — data class UiState(val users: List<User> = emptyList(), val isLoading: Boolean = false, val error: String? = null, val isRefreshing: Boolean = false). This approach lets you show old data while refreshing, display a loading spinner on top of existing content, or show a non-blocking error snackbar while keeping the content visible. These are common, good UX patterns that sealed class states can't express cleanly — you'd need to embed data inside your Loading and Error subclasses, which gets messy. The downside is that invalid combinations are possible (isLoading = true and isRefreshing = true simultaneously) — discipline and code review prevent these.
The hybrid approach and the anti-pattern to avoid — for complex screens, combine both: a sealed class for the content state (Content.Empty | Content.Loaded(users) | Content.Error) plus boolean flags for overlays (isRefreshing, isBottomSheetVisible, isUploadInProgress) all inside one data class. This captures the best of both. The critical anti-pattern to avoid: multiple separate StateFlows for related state — val isLoading: StateFlow<Boolean>, val data: StateFlow<List<User>>, val error: StateFlow<String?>. A race condition can leave isLoading = false and data = emptyList() and error = null simultaneously — a blank screen with no explanation. Single state object, always.
Pull-to-refresh is the litmus test. With sealed class, when refreshing you can't show "old data + spinner" — you'd lose the data. Data class handles this: isRefreshing=true while users still holds the previous list. This is why complex screens prefer data class state.
9. How do you handle navigation in a multi-module app?
Feature modules can't depend on each other, so navigation must go through a shared contract. The NavGraphBuilder extension pattern (used in Google's Now in Android) is the modern recommended approach.
// :core:navigation — shared routes (all features import this) @Serializable object HomeRoute @Serializable data class ProfileRoute(val userId: String) @Serializable data class ProductRoute(val productId: String) // :feature:home contributes its own graph via extension function fun NavGraphBuilder.homeGraph(navController: NavController) { composable<HomeRoute> { HomeScreen( onNavigateToProfile = { userId -> navController.navigate (ProfileRoute(userId)) } ) } } // :app assembles ALL feature graphs — it's the only module that knows all routes @Composable fun AppNavHost(navController: NavHostController) { NavHost(navController, startDestination = HomeRoute) { homeGraph(navController) // from :feature:home profileGraph(navController) // from :feature:profile productGraph(navController) // from :feature:product } } // :feature:home only imports :core:navigation — NOT :feature:profile // navController.navigate(ProfileRoute("123")) works without that import // Alternative: Deep links (fully decoupled but no compile-time safety) navController.navigate ("myapp://profile/123")
The core problem — in a multi-module app, feature modules can't import each other. :feature:home needs to navigate to :feature:profile, but adding :feature:profile as a dependency of :feature:home would create a coupling that defeats the purpose of modularization. Both modules need to remain independent. The solution: navigation contracts live in a shared :core:navigation module that both features can import, without either knowing about the other.
Type-safe routes with NavGraphBuilder extensions — define type-safe route objects in :core:navigation: @Serializable object HomeRoute; @Serializable data class ProfileRoute(val userId: String). Each feature module provides a NavGraphBuilder extension function that registers its screens: fun NavGraphBuilder.homeGraph(onNavigateToProfile: (String) -> Unit) { composable<HomeRoute> { HomeScreen(onNavigateToProfile) } }. These extensions are called in :app's single NavHost — the only module that knows the full navigation graph. Features declare their screens; :app wires them together. This is the pattern used in Google's Now in Android reference app.
Deep links as an alternative — for fully decoupled navigation (especially to external app entry points), deep links work without any direct module reference: navController.navigate(Uri.parse("myapp://profile/$userId")). The target screen registers the URI in its NavGraph definition. No import of the target module needed. This is the right choice for cross-app navigation and notification tap targets. The downside: string-based URIs lose compile-time type safety — a typo in the URI causes a silent failure at runtime rather than a build error. For in-app navigation between known modules, the type-safe route approach is safer.
This is exactly the pattern in Google's "Now in Android" project. Each feature module defines a fun NavGraphBuilder.featureGraph() extension, and :app's AppNavHost calls all of them. Feature modules stay completely isolated — :app is the only module that "knows" about all features.
10. Scenario: You're building a new e-commerce app for a 5-person team. What architecture would you choose and why?
For a 5-person team building a new e-commerce app, start with a single-module clean architecture (Presentation → Domain → Data), then migrate to multi-module only when build times hurt or team size grows. Premature modularisation adds weeks of Gradle setup with no day-one benefit.
// Recommended starting structure -- single module, clear package boundaries com.example.shop ├── data/ // repositories, Room DAOs, Retrofit APIs, DTOs ├── domain/ // use cases, domain models, repository interfaces ├── presentation/ // ViewModels, Compose screens, UI state └── di/ // Hilt modules // Domain layer -- pure Kotlin, no Android imports class GetProductsUseCase @Inject constructor( private val repo: ProductRepository // interface, not implementation ) { operator fun invoke() = repo.getProducts() } // When to add a module: build time > 2 min OR a clear reusable boundary exists
Start single-module with Clean Architecture package structure — for a 5-person team starting fresh, the right answer is a single Gradle module with clean package boundaries: data/ (repositories, DTOs, Room entities, Retrofit interfaces), domain/ (use cases, repository interfaces, domain entities — pure Kotlin), presentation/ (ViewModels, Composables, UI state), di/ (Hilt modules). You get almost all the architectural benefits of multi-module — clear boundaries, testable layers, domain isolation — with zero Gradle configuration overhead. On a 5-person team spending time wrestling with Gradle is engineering time not spent on product.
Non-negotiable: a pure domain layer — even in a single module, the domain layer must have no Android imports. No Context, no @Entity, no LiveData, no @Parcelize. If your domain package can't be extracted to a plain Kotlin module without removing any imports, it's not clean. Repository interfaces live in domain; Room-backed implementations live in data. ViewModels import from domain only. This discipline is what makes your business logic independently unit-testable — no emulator, no Robolectric, pure JVM tests that run in milliseconds.
When to modularize — the migration triggers — watch for two signals: (1) clean build time exceeds 2 minutes — extract :core:network and :core:ui first, since these change rarely and can be built once then cached; (2) features start conflicting in code reviews — developers from different features regularly touching the same files means the team has grown beyond what a single module can support cleanly. Feature modules come last — only split when a team member can genuinely own a feature end-to-end. Premature modularization on a 5-person team adds weeks of setup for a build time benefit that doesn't exist yet.
Show judgment, not pattern-matching. "I'd use Clean Architecture because the checkout flow has complex multi-step business rules. For the product catalog — plain MVVM is enough." Knowing when each pattern applies, and when it's over-engineering, is what separates senior from mid-level thinking.
11. What is the difference between @Binds and @Provides in Hilt?
@Provides executes code to create a dependency. @Binds declares which implementation satisfies an interface — no code, just a mapping. @Binds is more efficient and preferred for interface bindings.
// @Provides — runs code to create the object // Use when: third-party library (can't add @Inject), complex setup @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideRetrofit(): Retrofit = Retrofit.Builder() .baseUrl("https://api.example.com").build () @Provides @Singleton fun provideApi(retrofit: Retrofit): UserApi = // uses another dep retrofit.create (UserApi::class.java) } // @Binds — maps interface to implementation, zero code overhead // Use when: impl has @Inject constructor — Hilt already knows how to create it // Module MUST be abstract, method MUST be abstract @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { @Binds @Singleton abstract fun bindUserRepository( impl: UserRepositoryImpl // has @Inject constructor ): UserRepository // interface it satisfies @Binds abstract fun bindAnalytics(impl: FirebaseAnalytics): AnalyticsTracker } // Mix @Binds + @Provides in same module using companion object @Module @InstallIn(SingletonComponent::class) abstract class AppModule { @Binds abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository companion object { @Provides @Singleton fun provideDb(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build () } }
@Provides — for code that creates objects — use @Provides when you need to write actual code to construct the dependency: building an OkHttpClient with interceptors, creating a Retrofit instance with a base URL, initializing Room with a migration path. The function body executes when Hilt needs to provide the dependency: @Provides @Singleton fun provideRetrofit(okHttp: OkHttpClient): Retrofit = Retrofit.Builder().baseUrl(BASE_URL).client(okHttp).build(). Any parameters the function declares are automatically provided by Hilt from the dependency graph — you don't call provideRetrofit manually, Hilt does.
@Binds — for interface-to-implementation mapping — use @Binds when your class already has an @Inject constructor and you just need to tell Hilt "when someone asks for UserRepository (interface), give them UserRepositoryImpl (class)". @Binds is an abstract function with no body: @Binds abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository. It generates more efficient code than @Provides — no additional function call, no object allocation for the provider — because Hilt knows at compile time exactly what to inject. Prefer @Binds for your own classes whenever possible.
Mixing @Binds and @Provides in one module — @Binds requires the module to be abstract (it's an abstract function with no body). But @Provides functions need a body, which can't be in an abstract class. The solution is the companion object pattern: make the Hilt module class abstract (for @Binds), and put all @Provides functions in a companion object inside it. Hilt processes both correctly. This is a common pattern in production Hilt modules where you bind your own interfaces and also provide third-party dependencies in the same module file.
Rule of thumb: if you own the class and can add @Inject to its constructor, use @Binds. If it's a third-party class (Retrofit, OkHttp, Room) you can't annotate, use @Provides. @Binds generates leaner Dagger code — prefer it whenever possible.
12. What is the domain layer? What should and shouldn't go in it?
The domain layer is the heart of Clean Architecture — pure Kotlin, no Android dependencies. It defines what the app does, independent of how data is stored or displayed.
// ✅ BELONGS in domain layer // Entities — core business objects with business methods data class Order(val id: String, val items: List<OrderItem>, val status: OrderStatus) { val total: Double get() = items.sumOf { it.price * it.quantity } fun canBeCancelled() = status == OrderStatus.PENDING } // Repository INTERFACES (implemented in data layer) interface OrderRepository { fun observeOrders(): Flow<List<Order>> suspend fun cancelOrder(id: String): Result<Unit> } // Use cases with business rules class CancelOrderUseCase @Inject constructor(private val repo: OrderRepository) { suspend operator fun invoke(orderId: String): Result<Unit> { val order = repo.getOrder(orderId) check(order.canBeCancelled ()) { "Cannot cancel a ${order.status} order" } return repo.cancelOrder (orderId) } } // ❌ Does NOT belong in domain layer // import android.content.Context ← Android // import retrofit2.http.GET ← Retrofit (data layer) // import androidx.room.Entity ← Room (data layer) // import androidx.compose.* ← UI (presentation) // String formatting, date display ← presentation // domain/build.gradle.kts — pure Kotlin JVM module // plugins { kotlin("jvm") } — NO android plugin // dependencies { kotlinx-coroutines-core only }
What belongs in the domain layer — the domain layer contains the heart of your application's business logic: domain entities (plain Kotlin data classes representing business concepts), repository interfaces (abstract contracts, not implementations), use cases (single business operations), and domain-specific error types. Everything is pure Kotlin — no Android Gradle plugin, no @Entity, no Parcelable, no Context, no Retrofit annotations. The domain layer has no knowledge of how data is stored or displayed. It only knows what the business needs to do.
Domain entities vs DTOs vs Room entities — a common mistake is reusing network DTOs or Room entities as domain entities. These are three distinct types with different concerns. A DTO is shaped for network JSON serialization (@SerialName("user_id"), snake_case fields, nullable primitives). A Room entity is shaped for database schema (@PrimaryKey, @ColumnInfo). A domain entity is shaped for business logic — it should use rich types like Instant instead of Long for timestamps, enum class OrderStatus instead of String, and can contain business methods: fun Order.canBeCancelled(): Boolean = status == PENDING && createdAt.isAfter(Instant.now().minus(30, MINUTES)). Business rules live on the entity, not scattered in ViewModels.
Use cases — one class, one operation — each use case is a class with a single operator fun invoke() or suspend operator fun invoke(). PlaceOrderUseCase validates payment, calls orderRepository.createOrder(), triggers inventoryRepository.reserve(), and returns a Result<Order>. This orchestration lives in the domain, not the ViewModel. The ViewModel calls placeOrderUseCase(cart, paymentInfo) and handles the result — it doesn't know the steps involved. The purity test: if you can run every domain test with ./gradlew :domain:test on a CI machine with no Android SDK installed, your domain layer is genuinely clean.
The domain purity test: "Does this code import anything from Android, Room, Retrofit, or Compose?" If yes, it doesn't belong in domain. Pure domain tests run in milliseconds with no emulator — that's the practical payoff of keeping it clean.
13. What is the difference between MVVM and MVP? Why did MVVM win in Android?
MVP uses a Presenter that holds a direct View reference — causing lifecycle issues and memory leaks. MVVM uses observable state — ViewModel has no View reference, surviving configuration changes cleanly.
// MVP — Presenter holds View reference interface UserView { fun showUser(user: User) fun showLoading() fun showError(msg: String) } class UserPresenter(private var view: UserView?) { fun loadUser(id: String) { view?.showLoading () api.getUser (id) { view?.showUser (it) } // Problem: if Activity rotates, view reference is STALE } fun detach() { view = null } // must call in onDestroy — easy to forget! } // MVVM — ViewModel has ZERO View reference class UserViewModel : ViewModel() { val state = MutableStateFlow<UiState<User>>(UiState.Loading) // Rotation: new Activity subscribes to same StateFlow — gets current state // No detach() needed — nothing to clean up // ViewModel survives rotation in ViewModelStore } // Why MVVM won: // ✅ ViewModel retained by ViewModelStore — survives rotation // ✅ No View reference — no memory leaks // ✅ StateFlow/LiveData handles lifecycle naturally // ✅ Google Architecture Components built for MVVM // ✅ Compose is naturally MVVM: state flows down, events go up // MVP problems: // ❌ Presenter holds View → memory leak if detach() forgotten // ❌ No built-in rotation survival // ❌ View interface = boilerplate for every action
MVP — the pre-ViewModel approach — in MVP, the Presenter holds a direct reference to the View interface (interface LoginView { fun showError(msg: String); fun navigateToHome() }). The Activity implements this interface. On screen rotation, the Activity is destroyed and recreated — the Presenter must either be retained manually (complex) or recreated (losing state). You have to call presenter.detachView() in onDestroy() and presenter.attachView(this) in onCreate() to prevent null pointer crashes and memory leaks. Every screen requires this lifecycle choreography. One mistake and you're calling View methods on a detached, already-destroyed Activity.
MVVM — why it won — the ViewModel has no View reference at all. It exposes state via StateFlow; the View subscribes. On rotation, the Activity is destroyed but the ViewModel is retained by ViewModelStore — its state is preserved automatically. The new Activity instance subscribes to the same StateFlow and immediately receives the current state. No manual lifecycle management, no attach/detach calls, no null reference crashes. The ViewModel simply can't leak the View because it never holds one. Jetpack's ViewModel class was designed specifically to solve MVP's rotation problem.
Compose makes MVVM the clear winner — Compose's programming model is a natural fit for MVVM: state flows down into Composables as function parameters, events flow up as lambda callbacks. val uiState by viewModel.uiState.collectAsStateWithLifecycle() gives you a reactive binding with a single line. The Composable is a pure function of state — no lifecycle callbacks, no attach/detach, no View references. MVP's bidirectional reference model has no place in a world where UI is a function of state. MVVM (and MVI, which extends it) is the architecture Compose was built for.
The rotation argument is decisive: with MVP, the Activity is destroyed and the Presenter either leaks it or loses state. With MVVM, the ViewModel survives — the new Activity subscribes to the same StateFlow and immediately gets the current state. No boilerplate, no lifecycle bugs.
14. How do you test a ViewModel? What are best practices?
ViewModel tests verify that the right state is emitted for given inputs — using fake repositories, TestDispatcher for coroutines, and Turbine for Flow assertions.
// Fake repository — returns predictable data class FakeUserRepository : UserRepository { var userToReturn: User = User("1", "Alice") var shouldThrow = false override suspend fun getUser(id: String): User { if (shouldThrow) throw IOException("Network error") return userToReturn } } // ViewModel test @OptIn(ExperimentalCoroutinesApi::class) class UserViewModelTest { private val testDispatcher = StandardTestDispatcher() private val fakeRepo = FakeUserRepository() private lateinit var vm: UserViewModel @Before fun setUp() { Dispatchers.setMain (testDispatcher) vm = UserViewModel(fakeRepo) } @After fun tearDown() { Dispatchers.resetMain () } @Test fun loadUser_emitsSuccess() = runTest { vm.state.test { // Turbine assertEquals(UiState.Loading, awaitItem()) // initial vm.loadUser ("1") val success = awaitItem() as UiState.Success assertEquals("Alice", success.data.name) } } @Test fun loadUser_onError_emitsError() = runTest { fakeRepo.shouldThrow = true vm.state.test { awaitItem() // skip Loading vm.loadUser ("1") assertTrue(awaitItem() is UiState.Error ) } } }
Fakes over mocks — prefer handwritten FakeUserRepository over Mockito mocks for ViewModel tests. A fake is a real implementation of the interface that returns controllable data: class FakeUserRepository : UserRepository { var shouldThrow = false; var userToReturn = testUser; override suspend fun getUser(id: String) = if (shouldThrow) throw IOException() else userToReturn }. Fakes are readable (no mock configuration DSL), type-safe (compiler catches interface changes), and reusable across many tests. Mocks often break when you rename a method — fakes cause a compile error instead, which is caught immediately rather than discovered as a flaky test.
Coroutines testing setup — viewModelScope uses Dispatchers.Main, which doesn't exist on JVM. In your test, use @get:Rule val mainDispatcherRule = MainDispatcherRule() — a JUnit rule that calls Dispatchers.setMain(TestDispatcher) before each test and restores it after. This makes viewModelScope.launch use a controllable test dispatcher. Use runTest { } instead of runBlocking — it uses virtual time so delay() calls don't actually wait, and it automatically advances time to drain all pending coroutines before assertions.
Turbine for Flow assertions — the Turbine library makes asserting on Flow emissions straightforward: viewModel.uiState.test { val initial = awaitItem(); assertTrue(initial.isLoading); viewModel.loadUser("123"); val loaded = awaitItem(); assertEquals(testUser, loaded.user); cancelAndIgnoreRemainingEvents() }. Without Turbine, you'd need toList() with take(n), which requires knowing exactly how many items will be emitted — fragile and hard to read. Test both the happy path and error path: set fakeRepo.shouldThrow = true, trigger the action, assert the error state. Error path tests are where production bugs most often hide.
Fakes vs mocks: Fakes are hand-written classes (shouldThrow = true). Mocks use Mockk/Mockito frameworks. Prefer fakes for repositories — they're more readable and don't break when method signatures change. Use mocks only for complex interaction verification.
15. Scenario: A PR shows the ViewModel creating Retrofit directly and updating a TextView. What's wrong and how do you fix it?
This PR has 5 distinct architectural violations — identifying all of them systematically is what senior code reviews look like.
// ❌ BAD CODE — 5 violations class UserViewModel( private val textView: TextView // Bug 1: View reference in ViewModel! ) : ViewModel() { private val api = Retrofit.Builder() // Bug 2: creating Retrofit here! .baseUrl ("https://api.example.com").build ().create (UserApi::class.java) fun loadUser(id: String) { GlobalScope.launch { // Bug 3: GlobalScope — never cancelled! val user = api.getUser (id) // Bug 4: direct API call, no Repository textView.text = user.name // Bug 5: updating View from ViewModel! } } } // ✅ FIXED VERSION @HiltViewModel class UserViewModel @Inject constructor( // Fix 2: inject dependencies private val repo: UserRepository ) : ViewModel() { // Fix 1: no View reference private val _state = MutableStateFlow<UiState<User>>(UiState.Loading) val state = _state.asStateFlow () // Fix 5: expose state, not View fun loadUser(id: String) { viewModelScope.launch { // Fix 3: viewModelScope runCatching { repo.getUser (id) } // Fix 4: through Repository .onSuccess { _state.value = UiState.Success (it) } .onFailure { _state.value = UiState.Error (it.message !!) } } } }
Bug 1 — TextView in ViewModel — the ViewModel holds a TextView reference. When the Activity is rotated, the old Activity is destroyed, but the ViewModel is retained — and it's still holding a reference to the destroyed Activity's TextView. The garbage collector cannot collect the old Activity. Every rotation leaks the entire Activity view hierarchy. Fix: remove all View references from the ViewModel. The ViewModel exposes a StateFlow<String>; the Activity observes it and updates the TextView in its own lifecycle callback.
Bug 2 — Creating Retrofit in the ViewModel — val retrofit = Retrofit.Builder().baseUrl(...).build() inside a ViewModel means every ViewModel instance creates its own Retrofit and OkHttp client — with separate connection pools, separate thread pools, separate certificate pinning configuration. This wastes memory, creates inconsistency, and makes the ViewModel impossible to test without real network. Fix: inject a UserApi (or UserRepository) through the constructor via Hilt. The single Retrofit instance lives as a @Singleton in the Hilt graph, shared across the entire app.
Bug 3 — GlobalScope, Bug 4 — direct API call, Bug 5 — background thread UI update — GlobalScope.launch creates a coroutine that is never cancelled, outliving the ViewModel and continuing to run after the user has left the screen. Replace with viewModelScope.launch. Calling the API directly from the ViewModel bypasses the Repository — no caching, no consistent error handling, no testability. Extract a Repository and inject it. Finally, calling textView.text = result from a background coroutine is a threading violation — Android Views must only be updated on the main thread. With the ViewModel pattern, this bug disappears entirely because the ViewModel never touches Views: it updates a StateFlow, and the View layer observes it on the main thread.
Count violations systematically in a code review: memory leak → threading → scope → abstraction → separation of concerns. Identifying all 5 — not just the obvious one — shows you have a mental architectural checklist. This is what senior PR reviews look like.
16. Scenario: Your app has a 3-minute build time. How do you reduce it with modularization?
Build time improvement is a systematic process — measure first, take the free wins, then strategically modularize the highest-churn code.
// Step 1: Measure first — don't guess // ./gradlew assembleDebug --scan // Gradle build scan identifies which tasks dominate // Step 2: Free wins — no code changes needed // gradle.properties org.gradle.caching=true // reuse cached outputs org.gradle.parallel=true // build modules concurrently org.gradle.jvmargs=-Xmx4g // more heap for Gradle daemon kotlin.incremental=true // incremental Kotlin compilation // Expected improvement: 30-50% from these alone // Step 3: Modularise strategically // Extract :core:network first — changes rarely, no need to recompile often // Then :core:database, :core:ui // Then feature modules — highest churn, biggest incremental gains // Step 4: Use implementation() not api() // api(): exposes dep to consumers → cascade recompile on change // implementation(): keeps dep private → only this module recompiles dependencies { implementation(project(":core:network")) // ✅ private // api(project(":core:network")) ← leaks to consumers! } // Retrofit version bump + api() = all 10 modules recompile // Retrofit version bump + implementation() = only :core:network // Step 5: Convention plugins — consistent, maintainable modules // :build-logic defines reusable Gradle plugins // Each feature module: just 2-3 lines of plugins { }
Measure before optimizing — run ./gradlew assembleDebug --scan and examine the Gradle build scan at scans.gradle.com. The timeline view shows which tasks took the most time and which ones are not cacheable. Common findings: annotation processors (Hilt's Dagger codegen, Room's schema processing) running on every build because their inputs aren't properly declared; large modules that change frequently forcing everything downstream to recompile; missing build cache configuration. Never optimize blindly — 30 minutes reading a build scan prevents weeks of premature modularization.
Free wins — configuration changes only — before touching module structure, add these to gradle.properties: org.gradle.caching=true (reuse outputs from previous builds when inputs haven't changed), org.gradle.parallel=true (compile independent modules simultaneously), org.gradle.configureondemand=true (only configure modules needed for the requested task). These three lines can reduce clean build time by 30–50% with zero code changes. Also enable kapt.incremental.apt=true and kapt.use.worker.api=true to make annotation processing incremental.
Module structure strategy — stable code first — extract the modules that change least frequently first. :core:network (Retrofit setup, OkHttp, interceptors) changes maybe once a month — once compiled and cached, it's a free hit for every other build. :core:ui (shared composables, theme, typography) is similar. These modules pay back their extraction cost immediately. Switch all inter-module dependencies to implementation() instead of api() — api() exposes the dependency to all consumers, meaning a change triggers recompilation of every module in the chain. implementation() is private to the module, containing the recompilation cascade.
"Measure first with ./gradlew --scan" immediately shows engineering discipline. The biggest mistake: spending weeks on modularization before enabling caching and parallel builds — those are 2-minute config changes that give 40% improvement for free.
17. What are common Android design patterns? Explain Observer, Factory, Builder, and Strategy.
Design patterns are proven solutions to recurring problems. Connecting theory to real Android APIs — not just naming patterns — is what interviewers want to hear.
// OBSERVER — reactive data flow between layers // StateFlow, Flow, LiveData ARE the Observer pattern val state = MutableStateFlow(UiState.Loading) state.collect { render(it) } // multiple observers subscribe independently // FACTORY — create objects without specifying exact class // ViewModelProvider is a Factory interface AnalyticsTracker { fun track(event: String) } object AnalyticsFactory { fun create(debug: Boolean): AnalyticsTracker = if (debug) LogAnalyticsTracker() else FirebaseAnalyticsTracker() } // BUILDER — construct complex objects step by step val client = OkHttpClient.Builder() .connectTimeout (30, TimeUnit.SECONDS) .addInterceptor(AuthInterceptor()) . addInterceptor(LoggingInterceptor()) . build () // Also: Retrofit.Builder, AlertDialog.Builder, Room.Builder // STRATEGY — interchangeable algorithms interface SortStrategy { fun sort(items: List<Product>): List<Product> } class PriceSortStrategy : SortStrategy { override fun sort(items: List<Product>) = items.sortedBy { it.price } } class NameSortStrategy : SortStrategy { override fun sort(items: List<Product>) = items.sortedBy { it.name } } // PaymentProcessors (Stripe, Razorpay, UPI) are also Strategy pattern // SINGLETON — managed by Hilt @Singleton scope // Room database, OkHttp client — created once, shared everywhere
Observer pattern — the backbone of reactive Android — Observer allows objects to subscribe to state changes without polling. In Android, this is StateFlow, Flow, and LiveData. The ViewModel (subject) holds state; the View (observer) subscribes and re-renders on changes. Room's Flow<List<User>> return type is Observer pattern at the database layer — any write automatically notifies all active observers. Compose's recomposition model is Observer taken to its logical extreme: the entire UI tree re-renders its affected subtrees whenever observed state changes, with no explicit subscription management needed.
Factory and Builder patterns — Factory encapsulates object creation logic. ViewModelProvider is a factory that creates ViewModels with their dependencies injected; Hilt's @HiltViewModelFactory generates the factory code automatically. Hilt modules themselves are factories — they define how to construct each dependency. Builder pattern constructs complex objects step by step, making optional parameters explicit: OkHttpClient.Builder().connectTimeout(10, SECONDS).addInterceptor(loggingInterceptor).certificatePinner(pinner).build(). Without Builder, this constructor would need 15+ parameters with no indication of which are required. Both patterns appear constantly in Jetpack APIs — Notification.Builder, AlertDialog.Builder, NavOptions.Builder.
Strategy and Singleton patterns — Strategy defines a family of algorithms, encapsulates each one, and makes them interchangeable at runtime. A payment screen might support StripePaymentProcessor, RazorpayPaymentProcessor, and UpiPaymentProcessor — all implementing PaymentProcessor. The checkout flow calls processor.processPayment(amount) without knowing which processor is active. Swap strategies without changing the calling code. Singleton ensures a class has exactly one instance — the Room database, OkHttpClient, and analytics tracker should each have one instance for the app lifetime. In Hilt, annotate the provider with @Singleton and installed in SingletonComponent. Avoid manual singletons (companion object { val INSTANCE }) — they're hard to test and can't be injected with fakes.
Always connect patterns to real APIs. "StateFlow IS the Observer pattern." "ViewModelProvider IS a Factory." "Hilt @Singleton IS the Singleton pattern — but correctly scoped, not a static instance." Connecting theory to real code shows depth that just naming patterns doesn't.
18. What is the data layer? How do you map between DTOs and domain entities?
The data layer implements domain interfaces and handles all data concerns. DTOs match the network/database shape; domain entities represent business concepts — they must always be mapped separately.
// DTO — matches network JSON exactly @Serializable data class UserDto( val user_id: String, // snake_case from API val full_name: String, val created_at: Long, // epoch timestamp val is_premium: Boolean ) // Room Entity — matches database schema @Entity(tableName = "users") data class UserEntity( @PrimaryKey val id: String, val name: String, val createdAt: Long, val isPremium: Boolean ) // Domain Entity — clean, business-focused data class User( val id: String, val name: String, val createdAt: Instant, // proper type, not Long val tier: UserTier // richer type, not Boolean ) // Mappers — extension functions in data layer fun UserDto.toDomain() = User( id = user_id, name = full_name, createdAt = Instant.ofEpochSecond (created_at), tier = if (is_premium) UserTier.PREMIUM else UserTier.FREE ) fun UserEntity.toDomain () = User(id, name, Instant.ofEpochSecond (createdAt), if (isPremium) UserTier.PREMIUM else UserTier.FREE) fun User.toEntity () = UserEntity(id, name, createdAt.epochSecond, tier == UserTier.PREMIUM)
Three separate types, three separate concerns — the data layer works with three distinct model types that should never be conflated. A DTO (Data Transfer Object) is shaped for the network API: snake_case field names (@SerialName("user_id")), primitive types, nullable fields for optional JSON properties, and @Serializable annotation. A Room Entity is shaped for the database schema: @PrimaryKey, @ColumnInfo(name = "user_name"), @Embedded for nested objects, indices for query performance. A Domain Entity is shaped for business logic: rich types, non-null where the business guarantees it, business methods as extension functions.
Mappers in the data layer — the data layer contains mapper functions that translate between these types: UserDto.toDomain(): User, User.toEntity(): UserEntity, UserEntity.toDomain(): User. These mappers are the only code that knows about both sides of the conversion. The domain layer never sees a DTO. The ViewModel never sees a Room entity. This isolation means when the API changes (user_id becomes id), you update the DTO and the mapper — nothing else changes. The domain entity and all code that uses it are unaffected.
Why the separation matters at scale — in a small codebase, using one model class everywhere feels pragmatic. In a codebase with 200 files using the same model, it becomes a liability. When the API team renames a field, you update one DTO and one mapper — not 200 call sites. When the database schema changes to add an index or change a column type, you update the Room entity and mapper — not the business logic. When the domain model gains a new business method, it's in one place. The mapper pattern is the price you pay for this isolation — typically 10-15 lines per model type, paid once, saving days of refactoring later.
The maintenance argument for mapping: APIs change. If you use UserDto everywhere and the API renames a field, you update 200 files. With domain entities, you update one mapper. Domain entities are stable; DTOs are volatile. The mapper is the isolation buffer.
19. Scenario: Your app needs to support offline mode. How do you architect this?
Offline-first architecture treats the local database as the single source of truth. The UI always reads from the DB — the network just refreshes it in the background.
// Room DAO — reactive, auto-emits on any change @Dao interface ProductDao { @Query("SELECT * FROM products") fun observeAll(): Flow<List<ProductEntity>> // works offline immediately @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(products: List<ProductEntity>) } // Repository — observe DB, refresh from network class ProductRepository @Inject constructor( private val dao: ProductDao, private val api: ProductApi ) { fun getProducts(): Flow<List<Product>> = dao.observeAll ().map { it.map { e -> e.toDomain () } } suspend fun refresh(): Result<Unit> = runCatching { val fresh = withContext(Dispatchers.IO) { api.getProducts () } dao.insertAll (fresh.map { it.toEntity () }) // triggers observeAll Flow! } } // ViewModel — load DB immediately, refresh in background class ProductViewModel @Inject constructor(private val repo: ProductRepository) : ViewModel() { val products = repo.getProducts () .stateIn (viewModelScope, SharingStarted.WhileSubscribed (5000), emptyList()) private val _isOffline = MutableStateFlow(false) val isOffline = _isOffline.asStateFlow () init { viewModelScope.launch { repo.refresh ().onFailure { _isOffline.value = true } } } } // UI shows "Offline — showing cached data" banner when isOffline=true // Works without network: DB always has something to show
Database as the single source of truth — offline-first architecture means the UI always reads from Room, never directly from the network. The repository returns a Flow<List<Product>> from the Room DAO. When the app opens, the UI immediately receives whatever is in the database — even if the device is offline. Simultaneously, a network call runs in the background; when it completes, it writes results to Room. The Room Flow detects the change and automatically emits the fresh data to the UI. Users see cached content instantly, then see it update silently when fresh data arrives — no loading spinner for returning users.
Graceful failure handling — when the network call fails and the database already has data, show the cached data with a subtle indicator ("Last updated 2 hours ago") rather than a full error screen. A full error screen that hides existing data is poor UX — the user had data a second ago and now sees nothing. Only show a full error state when the database is empty and the network also fails — that's the genuinely unrecoverable case. Implement this in the repository: return the DB data immediately, attempt network refresh, and emit the error state only if both DB is empty and network fails.
WorkManager for background sync — when the device goes offline mid-session, any pending sync should be queued and executed when connectivity returns. WorkManager is the right tool: it persists across process death, respects system battery optimization, and supports network-required constraints: Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(). The sync work writes to Room; the active UI flows (if any) automatically pick up the changes. For the StateFlow in the ViewModel, use stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) — the 5-second keep-alive survives rotation without re-querying the database.
The mental model: "The UI asks the database, not the network." The network populates the database in the background. When offline, the user still sees stale data — show a "Last updated X minutes ago" indicator. This is the same pattern Google Maps and Instagram use.
20. What is Version Catalog (libs.versions.toml)? Why is it essential in multi-module projects?
Version Catalog centralizes all dependency declarations in one file. In multi-module apps, it prevents version conflicts and makes upgrades a single-line change across all modules.
// gradle/libs.versions.toml [versions] kotlin = "2.0.0" compose-bom = "2024.09.00" hilt = "2.51" retrofit = "2.11.0" room = "2.6.1" [libraries] hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } [bundles] room = ["room-runtime", "room-ktx"] // group related libs [plugins] hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-ksp = { id = "com.google.devtools.ksp", version = "2.0.0-1.0.21" } // :feature:home/build.gradle.kts — clean, type-safe plugins { id("myapp.android.feature") // convention plugin } dependencies { implementation(libs.hilt.android) // type-safe access ksp(libs.hilt.compiler) implementation(libs.bundles.room) // both room libs in one line } // Upgrade Hilt: change ONE line in toml → all 10 modules updated
The problem Version Catalog solves — in a multi-module project without Version Catalog, each module's build.gradle.kts declares its own dependency versions as strings: implementation("com.squareup.retrofit2:retrofit:2.11.0"). With 10 modules, you have 10 copies of this string. When Retrofit releases a security patch, you must find and update all 10 — and if you miss one, you've introduced a version conflict that Gradle will resolve unpredictably. Version Catalog puts all versions in one file (gradle/libs.versions.toml) with a single source of truth: update one line, all 10 modules automatically use the new version.
Type-safe accessors and bundles — in libs.versions.toml, you declare: [libraries] hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }. In any module's build file, you reference it as implementation(libs.hilt.android) — with IDE autocomplete, no string typos, and a compile error if the alias doesn't exist. Bundles group related libraries: [bundles] room = ["room-runtime", "room-ktx", "room-compiler"]. A module adds all three Room dependencies with implementation(libs.bundles.room) — one line instead of three, with guaranteed version alignment across all three artifacts.
Plugin versions centralized — the catalog also manages Gradle plugin versions: [plugins] android-application = { id = "com.android.application", version.ref = "agp" }. Applied in modules as alias(libs.plugins.android.application). When Android Gradle Plugin releases a new version, one line change in the catalog updates every module simultaneously. This is especially important for plugin compatibility — AGP, Kotlin, KSP, and Hilt plugin versions must be compatible with each other. With them all in one file, auditing and updating compatibility is a single focused task rather than hunting through 10 build files.
Without Version Catalog, "com.squareup.retrofit2:retrofit:2.11.0" appears in 10 build files. One module accidentally has "2.9.0" — classpath conflict, cryptic build failure. Version Catalog makes this physically impossible. Every multi-module project should start with it.
21. What is the difference between Unit tests, Integration tests, and UI tests?
The testing pyramid guides how many tests to write at each level. Clean Architecture makes each layer independently testable — unit tests at the bottom, UI tests at the top.
// UNIT TESTS — JVM only, milliseconds, 70% of test suite // Test: domain entities, use cases, ViewModels class OrderTotalTest { @Test fun total_sumsItemPrices() { val order = Order(items = listOf(Item(price = 10.0), Item(price = 20.0))) assertEquals(30.0, order.total, 0.001) } } // No Android runtime. Runs in <1ms. Domain layer pure Kotlin → ideal. // INTEGRATION TESTS — Android runtime, seconds, 20% of suite // Test: Repository with real Room, ViewModel with real UseCase @RunWith(AndroidJUnit4::class) class UserRepositoryTest { @Before fun setUp() { db = Room.inMemoryDatabaseBuilder (ctx, AppDatabase::class.java).build () } @Test fun insertUser_thenQuery_returnsUser() = runTest { dao.insert(UserEntity("1", "Alice")) assertEquals("Alice", dao. get ("1")?.name) } } // Needs emulator/Robolectric. Runs in seconds. // UI TESTS — full app, 10-30 seconds, 10% of suite // Test: user journeys, screen interactions @HiltAndroidTest class UserScreenTest { @get:Rule val rule = createAndroidComposeRule<MainActivity>() @Test fun userScreen_showsName() { rule.onNodeWithText ("Alice").assertIsDisplayed () rule.onNodeWithContentDescription ("Refresh").performClick() rule. onNodeWithText ("Refreshing...").assertIsDisplayed () } } // Needs emulator. Runs 10-30 seconds. Catches UI regression bugs.
Unit tests — the foundation (70%) — unit tests run on the JVM with no Android framework, completing in milliseconds. They test a single class in isolation with all dependencies replaced by fakes or mocks. In Clean Architecture, unit tests cover: domain entities (business rule logic), use cases (orchestration with fake repositories), and ViewModels (state transitions with fake use cases and TestDispatcher). Because the domain layer has no Android imports, its tests never need a device or emulator — they're identical to testing a backend Kotlin service. Fast feedback loop: 300 unit tests run in under 5 seconds. This speed is why you want the majority of your test suite here.
Integration tests — the middle layer (20%) — integration tests verify that two or more real components work together correctly. In Android, these typically run on a device or Robolectric and test: Room DAO queries against an in-memory database (do the SQL queries return the right data?), the repository with real Room but mocked network (does the caching strategy work?), or a use case with a real repository backed by Room. These tests catch mapping bugs (does the DTO-to-entity conversion handle null fields?), database query errors (does the JOIN produce the right results?), and integration mismatches that unit tests with fakes can't catch. They're slower (seconds per test) but cover a critical gap.
UI/End-to-end tests — the apex (10%) — UI tests run on a real emulator or device and exercise the full stack: real network (or WireMock), real Room, real ViewModel, real Compose UI. They verify complete user journeys: "user opens app, sees loading, data appears, taps an item, detail screen opens with correct data." These tests are slow (10–30 seconds each), flaky (animation timing, device variability), and expensive to maintain. Keep them to the most critical user paths — sign-in, checkout, core feature flows. The 70/20/10 pyramid ratio is a guideline: the exact numbers matter less than the principle that cheap, fast tests should vastly outnumber slow, expensive ones.
Specific libraries win interviews: "JUnit4 + Fakes for unit tests, Room inMemoryDatabase for integration, Hilt testing + createAndroidComposeRule for UI tests." Knowing the specific tools — not just the concept — shows you've actually written these tests.
22. What is Koin vs Hilt for DI? When would you choose each?
Hilt is compile-time, annotation-based — errors fail the build. Koin is a runtime DSL — errors crash at runtime. Both provide DI but differ fundamentally in safety and setup complexity.
// HILT — compile-time safety @Module @InstallIn(SingletonComponent::class) object AppModule { @Provides @Singleton fun provideRetrofit(): Retrofit = Retrofit.Builder().build () } @HiltViewModel class UserViewModel @Inject constructor(val repo: UserRepository) : ViewModel() // Missing binding → BUILD FAILS with clear error message // Zero performance cost at runtime // KOIN — runtime DSL val appModule = module { single { Retrofit.Builder().build () } factory { UserRepositoryImpl(get ()) } viewModel { UserViewModel(get ()) } } class MyApp : Application() { override fun onCreate() { super.onCreate (); startKoin { modules(appModule) } } } // Missing binding → RUNTIME CRASH on first injection // Small startup overhead for graph validation // Decision guide: // Hilt: ✅ Android apps, large teams, safety-critical, Google recommended // Koin: ✅ KMM (Kotlin Multiplatform), quick prototypes, simpler setup // Hilt is the 2025 standard for Android-only projects // Koin's multiplatform support makes it win for KMM projects
Hilt — compile-time safety, production-ready — Hilt generates all dependency injection code at compile time using Dagger's annotation processor (or KSP). If a binding is missing — you add a parameter to a ViewModel but forget to provide it — the build fails with a clear error message. Nothing reaches production with a missing dependency. At runtime, there's no reflection and no graph traversal overhead; the generated code is direct function calls. The trade-off is setup complexity: you need @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel, module classes with @InstallIn annotations. There's a learning curve, but it pays off in any app with more than a few screens.
Koin — simplicity, runtime flexibility — Koin uses a Kotlin DSL to define the dependency graph: module { single { Retrofit.create(UserApi::class.java) }; viewModel { UserViewModel(get()) } }. No annotation processing, no generated code, no build configuration beyond adding the dependency. The graph is constructed at runtime — missing dependencies only surface when the code that needs them runs, not at build time. Koin 3.x added optional compile-time verification, but it's not as comprehensive as Hilt's guarantees. Startup cost is minimal for typical app sizes but measurable on very large dependency graphs.
When to choose each — the 2025 guidance — for a pure Android app, Hilt is the default recommendation. It's Google's official DI solution, has first-class Jetpack integration (@HiltViewModel, WorkerFactory, SavedStateHandle injection), and the compile-time safety is genuinely valuable in team settings where everyone adds dependencies. For Kotlin Multiplatform (KMP) projects, Hilt is not an option — it's Android-only. Koin has KMP support and can share DI definitions across Android and iOS. If your roadmap includes KMP, Koin (or Kotlin-inject) is the right choice. The migration from one to the other is painful, so choose with your platform strategy in mind.
"Hilt fails at build time if I forget to provide a dependency. Koin fails at runtime — in front of users." This safety argument is what interviewers want. For production Android apps with a team, build-time safety over developer convenience every time.
23. Scenario: Your ViewModel is 500+ lines. How do you refactor it?
A 500-line ViewModel is a code smell — it has too many responsibilities. Delegate to use cases, state holders, and helper classes — each with a single, clear purpose.
// Fat ViewModel — 500 lines doing everything class CheckoutViewModel : ViewModel() { // 100 lines: address validation and selection // 100 lines: payment card validation // 150 lines: order placement + retry logic // 100 lines: promo code calculation // 50 lines: analytics tracking } // Refactor 1: Extract use cases — business logic out of VM class ValidatePaymentUseCase @Inject constructor() { operator fun invoke(card: CreditCard): ValidationResult { /* ... */ } } class PlaceOrderUseCase @Inject constructor(private val orderRepo: OrderRepository, ...) { suspend operator fun invoke(order: Order): Result<OrderId> { /* ... */ } } class ApplyPromoUseCase @Inject constructor() { suspend operator fun invoke(code: String): Discount? { /* ... */ } } // Refactor 2: StateHolder for UI-level state slices class AddressStateHolder @Inject constructor(private val repo: AddressRepository) { val selected = MutableStateFlow<Address?>(null) fun select(address: Address) { selected.value = address } } // Slim ViewModel — now ~80 lines, orchestrates only @HiltViewModel class CheckoutViewModel @Inject constructor( val addressHolder: AddressStateHolder, private val validatePayment: ValidatePaymentUseCase, private val placeOrder: PlaceOrderUseCase, private val applyPromo: ApplyPromoUseCase ) : ViewModel() { fun checkout(card: CreditCard) { val validation = validatePayment(card) if (!validation.isValid) { showError(validation.error); return } viewModelScope.launch { placeOrder(buildOrder()) } } }
Diagnose before refactoring — a 500-line ViewModel typically has accumulated several responsibilities: business logic that belongs in use cases, UI state management for multiple independent sections of the screen, formatting/transformation logic, and sometimes data access logic that bypassed the repository. The first step is categorizing each method: is this a business operation (extract to use case)? Is this managing state for one section of the screen (extract to state holder)? Is this formatting data for display (extract to a mapper or presenter model converter)? Different diagnoses lead to different refactoring strategies.
Extracting use cases — for each distinct business operation in the ViewModel, create a use case class: ValidatePaymentUseCase, PlaceOrderUseCase, ApplyPromoCodeUseCase. Move the business logic into operator fun invoke(), inject the repository, and inject the use case into the ViewModel. The ViewModel calls placeOrderUseCase(cart, payment) and handles the Result<Order> — it doesn't know the steps. Each use case is independently testable with a fake repository and has a focused, single-purpose test suite. The ViewModel shrinks by each function body you extract.
State holders for complex UI sections — if the screen has multiple semi-independent sections (shipping address form, payment method selection, order summary), extract a StateHolder for each: class AddressStateHolder(private val savedState: SavedStateHandle) { val address: StateFlow<Address> = ...; fun updateStreet(street: String) { ... } }. State holders are plain Kotlin classes (not ViewModels) injected into or created by the ViewModel. The ViewModel combines their state into the final UiState. The target: an 80-line ViewModel that creates state holders, calls use cases, combines their outputs into a single StateFlow, and handles navigation events. Coordination, not implementation.
StateHolders are underused. An AddressStateHolder manages address selection, validation, and saving — completely independently of the ViewModel. The ViewModel just exposes addressHolder.selected. This keeps ViewModel slim without pushing logic into the View layer.
24. Scenario: Your team debates MVVM vs MVI for a complex order tracking screen with real-time WebSocket updates, multiple data sources, and a cancel button. Which do you choose?
When a screen has many concurrent inputs (WebSocket, user actions, polling) and complex state transitions, MVI's single state object is the right choice — it prevents impossible UI combinations.
// MVI — justified by complexity of this screen data class OrderTrackingState( val order: Order? = null, val driverLocation: LatLng? = null, val eta: String? = null, val isLoading: Boolean = true, val isCancelling: Boolean = false, val error: String? = null ) // Impossible to have: isCancelling=true AND order=null AND error != null // (data class copy() ensures consistent updates) sealed class OrderIntent { data class Load(val id: String) : OrderIntent() object Cancel : OrderIntent() data class DriverMoved(val loc: LatLng) : OrderIntent() // from WebSocket data class StatusChanged(val status: OrderStatus) : OrderIntent() } @HiltViewModel class OrderTrackingViewModel @Inject constructor( private val orderRepo: OrderRepository, private val wsService: WebSocketService ) : ViewModel() { private val _state = MutableStateFlow(OrderTrackingState()) val state = _state.asStateFlow () fun dispatch(intent: OrderIntent) { viewModelScope.launch { when (intent) { is OrderIntent.Load -> loadOrder(intent.id) is OrderIntent.Cancel -> cancelOrder() is OrderIntent.DriverMoved -> _state.update { it.copy (driverLocation = intent.loc) } is OrderIntent.StatusChanged -> handleStatusChange(intent.status) } } } }
Why this screen is the ideal MVI use case — the order tracking screen has at least four simultaneous event sources: WebSocket emissions (driver location updates every 5 seconds), user actions (tap cancel, tap chat, pull to refresh), polling (ETA recalculation), and push notifications (order status changes). In MVVM, each source might update its own StateFlow — driverLocation, eta, orderStatus, error — and a race condition can leave them inconsistent: the driver is shown as moving while the order is also shown as cancelled. These bugs are nearly impossible to reproduce because they depend on exact event timing.
MVI enforces consistency — in MVI, every event becomes an Intent: sealed class Intent { data class DriverLocationUpdated(val lat: Double, val lng: Double) : Intent(); object CancelOrderTapped : Intent(); data class EtaUpdated(val minutes: Int) : Intent(); object ErrorOccurred : Intent() }. All intents flow through a single reduce(state, intent) -> state function. State transitions are atomic — the entire state object is replaced atomically. It's impossible to have a partially-updated state because all related fields update together in the same copy() call. The single state object is the consistency guarantee.
Testability and debugging — MVI makes this complex screen straightforward to test: inject a sequence of intents and assert the final state deterministically. viewModel.processIntent(DriverLocationUpdated(12.9, 77.5)); viewModel.processIntent(EtaUpdated(3)); assertEquals(UiState(driverLat=12.9, eta=3, isCancelled=false), viewModel.state.value). Log every intent in production and you can replay any reported bug exactly — you know the exact sequence of events that produced the broken state. For a simpler screen (a profile page that loads once and allows edits), MVVM is lighter and perfectly adequate. The added ceremony of MVI is only worth it when simultaneous event sources create real consistency risk.
Frame it as risk: "With MVVM and 6 separate StateFlows for this screen, I'd need to carefully combine them — prone to race conditions. With MVI's single state, consistency is guaranteed by the data class copy() mechanism. For a screen this complex, MVI's overhead is worth the safety guarantee."
25. Scenario: Explain how data flows from server to UI in your Clean Architecture app — as if onboarding a junior developer.
A clear end-to-end data flow explanation demonstrates your architectural understanding. Being able to teach it to a junior shows mastery — not just memorization.
// Full journey: Button click → Server → UI update // Step 1: UI triggers action (Compose) Button(onClick = { vm.loadUser ("123") }) { Text("Load Profile") } // Step 2: ViewModel calls use case class UserViewModel(private val getUser: GetUserUseCase) : ViewModel() { fun loadUser(id: String) { _state.value = UiState.Loading // immediate feedback viewModelScope.launch { runCatching { getUser(id) } .fold ({ _state.value = UiState.Success (it) }, { _state.value = UiState.Error (it.message !!) }) } } } // Step 3: Use case validates, calls repository interface class GetUserUseCase(private val repo: UserRepository) { suspend operator fun invoke(id: String): User { require(id.isNotBlank ()) { "ID cannot be blank" } return repo.getUser (id) } } // Step 4: Repository decides cache vs network class UserRepositoryImpl(private val api: UserApi, private val dao: UserDao) { override suspend fun getUser(id: String): User { dao.get (id)?.toDomain ()?.let { return it } // 4a: cache hit val dto = withContext(Dispatchers.IO) { api.getUser (id) } // 4b: network dao.insert (dto.toEntity ()) // 4c: save return dto.toDomain () // 4d: return } } // Step 5: StateFlow update → Compose recomposes val state by vm.state.collectAsStateWithLifecycle () when (state) { is UiState.Loading -> CircularProgressIndicator() is UiState.Success -> UserCard(state.data) is UiState.Error -> ErrorMessage(state.msg) }
From user tap to ViewModel — the user taps "Load Profile" in the Compose UI. The Composable calls a lambda passed down from the ViewModel: onLoadProfile = { viewModel.loadProfile(userId) }. The View never calls the Repository directly — it only communicates with the ViewModel. The ViewModel is the View's only dependency. This one-directional coupling is what makes the View replaceable: you can swap the Composable for a Fragment, a widget, or a test, and the ViewModel doesn't care.
From ViewModel through Domain to Data — the ViewModel calls getProfileUseCase(userId). The use case validates business preconditions (is the userId valid? does the user have permission to view this profile?), then calls userRepository.getUser(userId). The repository is an interface defined in the domain layer — the use case has no idea whether the implementation uses Room, Retrofit, or hardcoded test data. The real implementation in the data layer checks the cache, makes a network call if needed, maps the network DTO to a domain entity (userDto.toDomain()), saves it to Room, and returns the domain entity. The domain entity travels back up through the use case to the ViewModel.
From ViewModel to UI — the reactive update — the ViewModel receives the domain entity and updates its StateFlow: _uiState.update { it.copy(isLoading = false, user = result.getOrNull(), error = result.exceptionOrNull()?.message) }. The Composable collecting this StateFlow with collectAsStateWithLifecycle() is automatically recomposed by Compose with the new state — no manual UI update calls, no notifyDataSetChanged(). For the junior developer summary: data starts in the cloud, passes through the Repository (data layer), through the UseCase (domain layer), arrives at the ViewModel (presentation layer), and Compose renders it automatically. Each layer only knows about the layer directly inside it — never about layers outside it.
Draw this as a flow diagram while answering: Button → dispatch() → UseCase.invoke() → Repository.getUser() → API/DB → toDomain() → StateFlow update → Compose recomposition. Each arrow has direction and each layer has one job. This visual walkthrough is exactly what senior architects communicate.
26. What are Hilt scopes and how do they affect dependency lifetime? When does a @Singleton leak memory?
Hilt scopes tie dependency lifetime to a component's lifecycle. Choosing the wrong scope causes either memory leaks (too long) or redundant object creation (too short).
// Scope lifetime hierarchy (longest → shortest): // @Singleton → lives as long as Application // @ActivityRetainedScoped → survives rotation, dies when Activity finishes // @ActivityScoped → dies on every rotation // @ViewModelScoped → dies when ViewModel is cleared // @FragmentScoped → dies when Fragment is detached // @ViewScoped → dies when View is destroyed // ✅ Correct @Singleton — no Android Context stored @Module @InstallIn(SingletonComponent::class) object NetworkModule { @Provides @Singleton fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build () @Provides @Singleton fun provideDb(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db"). build () // ✅ @ApplicationContext — lives as long as app, no leak } // ❌ MEMORY LEAK — @Singleton holding Activity Context @Provides @Singleton fun provideAnalytics(activity: Activity): Analytics = Analytics(activity) // Activity LEAKED — Singleton outlives Activity! // Fix: use @ActivityRetainedScoped or @ApplicationContext // ❌ LEAK — storing Activity in @Singleton service @Singleton class NavigationService @Inject constructor() { private var activity: Activity? = null // LEAK after rotation! fun setActivity(a: Activity) { activity = a } } // @ViewModelScoped — each ViewModel gets its own instance @Module @InstallIn(ViewModelComponent::class) abstract class ViewModelModule { @Binds @ViewModelScoped abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository } // Each ViewModel gets its own UserRepositoryImpl instance // Two ViewModels → two separate repository instances
The scope hierarchy and what each means — @Singleton lives for the entire application lifetime — one instance shared across every screen. Use it for Retrofit, OkHttp, Room, and application-wide repositories. @ActivityRetainedScoped is less known but important: it survives screen rotation (like ViewModel) but is destroyed when the Activity is permanently finished. Use it for per-Activity shared state that must outlive rotation. @ViewModelScoped ties the instance to a specific ViewModel — each ViewModel gets its own instance, destroyed when the ViewModel is cleared. @ActivityScoped and @FragmentScoped die on rotation — rarely useful for data dependencies, mostly for UI helpers.
The @Singleton memory leak pattern — the most common Hilt mistake is injecting an Activity-scoped Context into a @Singleton class. Since the Singleton lives forever but the Activity is recreated on rotation, the Singleton holds a strong reference to the destroyed Activity — preventing garbage collection. The correct type for Singleton dependencies is always @ApplicationContext Context, which lives as long as the app. Hilt enforces this: injecting an unqualified Context in a Singleton-scoped provider causes a build error, because Hilt knows Context is ambiguous and forces you to be explicit with @ApplicationContext or @ActivityContext.
Scope mismatch — wrong direction — you can only inject a dependency into a component of equal or shorter lifetime. A @Singleton can't hold a @ViewModelScoped dependency (ViewModel dies first), and a @ViewModelScoped class can't hold an @ActivityScoped dependency (Activity dies on rotation while ViewModel survives). Hilt detects these mismatches at compile time — the build fails with a scope violation error. This compile-time enforcement is one of Hilt's most valuable features: scope bugs in manual DI only surface as crashes or leaks at runtime.
The @Singleton + Activity context leak is the most common Hilt mistake. Always use @ApplicationContext in Singleton-scoped dependencies. If you need Activity context, your dependency should be @ActivityScoped or @ActivityRetainedScoped — not @Singleton.
27. Scenario: Your team needs to add analytics tracking to every user action across 20 screens — without touching every screen.
Cross-cutting concerns like analytics should not be scattered across 20 screens. Use the navigation observer, Decorator pattern, or a base ViewModel — add tracking once, get it everywhere.
// Pattern 1: Navigation observer — tracks ALL screen views in ONE place @Composable fun AppNavHost(navController: NavHostController) { val analytics = LocalAnalytics.current val currentEntry by navController.currentBackStackEntryAsState () LaunchedEffect(currentEntry) { // fires on every navigation currentEntry?.destination?.route?.let { route -> analytics.trackScreen (route) // ONE place for all screen tracking } } NavHost(navController, HomeRoute) { /* feature graphs */ } } // Pattern 2: Decorator — wraps repository to add tracking class AnalyticsUserRepository @Inject constructor( private val delegate: UserRepository, private val analytics: Analytics ) : UserRepository by delegate { // Kotlin delegation — delegates everything override suspend fun getUser(id: String): User { analytics.track ("get_user", mapOf("id" to id)) // added here return delegate.getUser (id) } // All other methods delegated automatically — no boilerplate } // Bind decorator via Hilt @Module @InstallIn(SingletonComponent::class) abstract class RepoModule { @Binds @Singleton abstract fun bindRepo(impl: AnalyticsUserRepository): UserRepository // AnalyticsUserRepository wraps UserRepositoryImpl transparently } // Pattern 3: Base ViewModel — shared tracking across all ViewModels abstract class TrackedViewModel( private val analytics: Analytics, protected val screenName: String ) : ViewModel() { init { analytics.trackScreen (screenName) } protected fun track(event: String, params: Map<String, Any> = emptyMap()) { analytics.track (event, params) } } class HomeViewModel @Inject constructor(analytics: Analytics) : TrackedViewModel(analytics, "home_screen") { fun onProductClick(id: String) { track("product_click", mapOf("product_id" to id)) } }
Navigation observer — zero per-screen code — the cleanest approach for screen view tracking is a single navigation listener attached to the NavController. One LaunchedEffect in the root Composable collects navController.currentBackStackEntryFlow and logs every screen transition automatically: LaunchedEffect(navController) { navController.currentBackStackEntryFlow.collect { entry -> analytics.logScreenView(entry.destination.route) } }. Adding a new screen requires zero analytics code — it's tracked automatically the moment it appears in the nav graph. This is the purest form of the cross-cutting concern pattern: one place, total coverage.
Decorator pattern for event-level tracking — for tracking specific actions (button taps, purchases, errors), wrap your repositories with analytics-aware decorators using Kotlin delegation: class AnalyticsUserRepository(private val delegate: UserRepository, private val analytics: Analytics) : UserRepository by delegate { override suspend fun getUser(id: String) = delegate.getUser(id).also { analytics.log("user_loaded", id) } }. With Hilt, swap in the decorator via @Binds: bind AnalyticsUserRepository wherever UserRepository is needed. The ViewModel and use cases never see the analytics — they call the same interface as before.
Base ViewModel for shared analytics context — for tracking that needs ViewModel-level context (user ID, session ID, current screen state), a base ViewModel class works: abstract class TrackedViewModel(protected val analytics: Analytics) : ViewModel() { fun logEvent(name: String, params: Map<String, Any> = emptyMap()) { analytics.log(name, params) } }. All feature ViewModels extend it. Adding a new tracked event is one line anywhere in any ViewModel. The key principle across all three approaches: never scatter tracking code in 20 individual Composables. When the analytics team changes an event name or adds a required parameter, you update one place — not 20.
The navigation observer is the cleanest answer for screen tracking — one LaunchedEffect, zero changes to screens. For business events, the Decorator pattern is best — wrap the use case or repository, add tracking, Hilt wires it transparently. No screen code changes at all.
28. What is the Result type in Kotlin? How does it improve error handling compared to throwing exceptions?
Result makes error handling explicit in the return type — callers can't accidentally ignore failure. Exceptions can silently propagate; Result forces the caller to handle both cases.
// Kotlin built-in Result — wraps success or failure suspend fun fetchUser(id: String): Result<User> = runCatching { api.getUser (id) // any exception becomes Result.failure() } // Caller MUST handle both cases fetchUser("123") .onSuccess { user -> _state.value = UiState.Success (user) } .onFailure { e -> _state.value = UiState. Error (e.message !!) } // Custom sealed Result — richer error types sealed class NetworkResult<out T> { data class Success<T>(val data: T) : NetworkResult<T>() data class HttpError(val code: Int, val msg: String) : NetworkResult<Nothing>() data class NetworkError(val cause: Throwable) : NetworkResult<Nothing>() } // API layer converts exceptions → typed errors suspend fun <T> safeCall(call: suspend () -> T): NetworkResult<T> = try { NetworkResult.Success (call()) } catch (e: HttpException) { NetworkResult.HttpError (e.code (), e.message ()) } catch (e: IOException) { NetworkResult.NetworkError (e) } // Exhaustive when — compiler forces handling ALL cases when (val result = repo.getUser (id)) { is NetworkResult.Success -> showUser(result.data) is NetworkResult.HttpError -> showError("Server ${result.code}") is NetworkResult.NetworkError -> showError("No internet") }
Why Result over exceptions — when a function throws an exception, the caller can forget to catch it. The compiler doesn't enforce error handling for exceptions — it's easy to call userRepository.getUser(id) and never consider the failure case. Result<T> makes the failure case part of the return type. The caller receives Result<User> and must explicitly handle both .onSuccess { } and .onFailure { }. This explicit contract prevents the "forgot to handle the error" class of bugs that throws uncaught exceptions in production.
runCatching and sealed Result types — runCatching { api.getUser(id) } wraps any suspend function call in a Result with one line — no try-catch boilerplate. For richer error handling, define a sealed class: sealed class NetworkResult<T> { data class Success<T>(val data: T) : NetworkResult<T>(); data class HttpError<T>(val code: Int, val message: String) : NetworkResult<T>(); data class NetworkError<T>(val cause: IOException) : NetworkResult<T>() }. Now the ViewModel can show "No internet connection" vs "Server error 500" vs "Success" — three distinct UI states from three distinct result types. The exhaustive when expression ensures you never accidentally miss a case.
When to use Result vs exceptions — use Result for expected, recoverable failures: network calls that might fail, user input validation that might reject, database queries that might return empty. These are normal operating conditions, not exceptional events. Use exceptions for programming errors: null pointer from unexpected state, illegal argument, array out of bounds — conditions that indicate a bug, not a runtime scenario. The boundary is: "if the business logic has to handle this failure, it belongs in the return type." Repository functions that call the network should always return Result<T>. Use cases that call those repositories propagate Result upward to the ViewModel.
Key argument: with exceptions, getUser() can be called without a try-catch — the compiler won't complain but production will crash. With Result<User>, the caller must unwrap — error handling is enforced at compile time. This is the shift from "hope it works" to "prove it works."
29. Scenario: Two feature modules need to share user session data. How do you do this without creating a circular dependency?
Feature modules can't reference each other — all shared data must flow through a :core module. Three patterns work: shared interface module, app-level ViewModel, or a typed event bus.
// Pattern 1: :core:session — shared interface module // Both :feature:auth and :feature:cart import :core:session // :core:session/SessionManager.kt interface SessionManager { val currentUser: StateFlow<User?> suspend fun getToken(): String? fun isLoggedIn(): Boolean } // :feature:auth implements it class SessionManagerImpl @Inject constructor( private val prefs: SecurePreferences ) : SessionManager { private val _user = MutableStateFlow<User?>(null) override val currentUser = _user.asStateFlow () override suspend fun getToken() = prefs.getToken () override fun isLoggedIn() = _user.value != null } // :app binds it via Hilt @Binds @Singleton abstract fun bindSession(impl: SessionManagerImpl): SessionManager // :feature:cart uses it — no dependency on :feature:auth class CartViewModel @Inject constructor( private val session: SessionManager // from :core:session ) : ViewModel() { val user = session.currentUser.stateIn (viewModelScope, SharingStarted.Eagerly , null) } // Pattern 2: SharedFlow event bus in :core:events sealed class AppEvent { data class UserLoggedIn(val user: User) : AppEvent() object UserLoggedOut : AppEvent() } class AppEventBus @Inject constructor() { private val _events = MutableSharedFlow<AppEvent>() val events = _events.asSharedFlow () suspend fun emit(event: AppEvent) { _events.emit (event) } }
The shared interface in :core — the standard solution — define a SessionManager interface in :core:session. Both :feature:home and :feature:profile depend on :core:session and call sessionManager.getCurrentUser(). Neither feature knows about the other — they both depend on the same core abstraction. The concrete SessionManagerImpl lives in :data:session or :app, and Hilt wires the implementation via @Binds. The dependency arrows always point inward: feature → core → never feature → feature. This is the cleanest solution for shared stateful data.
App-level ViewModel for shared reactive state — for session state that the UI needs to react to (like showing a login prompt when the session expires), an app-level ViewModel scoped to the root NavHost works well: val sessionViewModel: SessionViewModel by activityViewModels(). Since it's scoped to the Activity (and therefore the root NavGraph), every feature screen can access the same ViewModel instance. Feature screens observe sessionViewModel.currentUser as a StateFlow — when the user logs out, all screens react simultaneously without any cross-feature coupling.
SharedFlow event bus for decoupled communication — for events that cross feature boundaries (user logged out, subscription upgraded, language changed), a typed event bus in :core:events works well: sealed class AppEvent { object UserLoggedOut : AppEvent(); data class SubscriptionChanged(val tier: Tier) : AppEvent() }. A singleton EventBus class (injected via Hilt) exposes a SharedFlow<AppEvent>. Any module can emit; any module can collect. This is more loosely coupled than the shared interface approach — emitters don't know who's listening — making it better for optional reactions (feature A doesn't need to know if feature B handles the event or not).
Draw the dependency graph: :feature:auth → :core:session. :feature:cart → :core:session. :app → :feature:auth + :feature:cart + binds SessionManagerImpl. Never :feature:auth → :feature:cart. The rule: arrows point toward :core, never between features.
30. What are convention plugins (build-logic)? How do they reduce Gradle duplication in multi-module projects?
Convention plugins extract repeated Gradle configuration into reusable plugins — DRY principle for build scripts. Instead of copying 50 lines to 10 modules, you apply one plugin per module.
// Without convention plugins — copied to every feature module: // :feature:home, :feature:profile, :feature:cart — all identical! plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("com.google.dagger.hilt.android") id("com.google.devtools.ksp") } android { compileSdk = 35; defaultConfig { minSdk = 24 }; ... } dependencies { implementation(libs.hilt.android); ksp(libs.hilt.compiler); ... } // 50 lines × 10 modules = 500 lines of duplication // WITH convention plugins: // build-logic/convention/src/main/kotlin/AndroidFeaturePlugin.kt class AndroidFeaturePlugin : Plugin<Project> { override fun apply(target: Project) = with(target) { pluginManager.apply ("com.android.library") pluginManager.apply ("org.jetbrains.kotlin.android") pluginManager.apply ("dagger.hilt.android.plugin") extensions.configure <LibraryExtension> { compileSdk = 35 defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } val libs = extensions.getByType <VersionCatalogsExtension>().named ("libs") dependencies { add("implementation", libs.findLibrary ("hilt-android").get ()) add("ksp", libs.findLibrary ("hilt-compiler").get ()) } } } // build-logic/convention/build.gradle.kts — register it gradlePlugin { plugins { register("androidFeature") { id = "myapp.android.feature"; implementationClass = "AndroidFeaturePlugin" } } } // :feature:home/build.gradle.kts — now just 2 lines! plugins { id("myapp.android.feature") // applies all 50 lines above id("myapp.android.compose") // adds Compose config } // 10 modules × 2 lines = 20 lines total (vs 500 before)
The problem convention plugins solve — in a 10-module project without convention plugins, every module's build.gradle.kts contains the same 40–60 lines: android { compileSdk = 35; defaultConfig { minSdk = 26; targetSdk = 35 }; compileOptions { sourceCompatibility = JavaVersion.VERSION_17 } }; kotlin { jvmToolchain(17) }. Multiply by 10 modules and you have 500 lines of duplicated configuration. When you need to raise compileSdk to 36, you update 10 files. Miss one and you have a subtle inconsistency that causes hard-to-diagnose build errors.
How convention plugins work — create a :build-logic composite build (a separate Gradle project included in settings.gradle.kts). Inside it, write Gradle plugins as Kotlin files: class AndroidFeatureConventionPlugin : Plugin<Project> { override fun apply(target: Project) { with(target) { plugins.apply("com.android.library"); android { compileSdk = 35; defaultConfig { minSdk = 26 } } } } }. Register the plugin in build-logic/build.gradle.kts. Now each feature module's entire build file is: plugins { id("myapp.android.feature"); id("myapp.android.hilt") }. Two lines. Raise compileSdk in one plugin file — all 10 modules update instantly.
Multiple focused plugins — composition over inheritance — define granular plugins that compose: myapp.android.library (basic Android library config), myapp.android.feature (library + ViewModel + navigation dependencies), myapp.android.compose (Compose compiler + UI dependencies), myapp.android.hilt (Hilt plugin + dependencies), myapp.android.testing (test dependencies + JUnit rules). A feature module that uses Compose and Hilt applies three plugins. A core network module applies only myapp.android.library. This is the pattern used in Google's Now in Android reference app — it's the current industry standard for managing Gradle complexity in large Android projects.
Mention Google's "Now in Android" — it's the canonical example of convention plugins. The impact: changing minSdk from 24 to 26 goes from editing 10 build files to changing one number in one plugin. Huge maintenance win for multi-module projects with 6+ modules.
31. What is SavedStateHandle? How does it differ from ViewModel state survival?
ViewModel survives configuration changes (rotation) but NOT process death. SavedStateHandle persists across process death using Bundle — the OS kills and restores it automatically.
// ViewModel survival table: // Screen rotation → ViewModel SURVIVES (ViewModelStore) // App backgrounded (hours) → Process KILLED by OS — ViewModel LOST // "Don't keep activities" → Process KILLED — ViewModel LOST // Back pressed → ViewModel LOST (intentional) // SavedStateHandle — survives process death via Bundle @HiltViewModel class SearchViewModel @Inject constructor( private val saved: SavedStateHandle, // auto-injected by Hilt private val repo: SearchRepository ) : ViewModel() { // getStateFlow — returns StateFlow backed by SavedState val query: StateFlow<String> = saved.getStateFlow ("query", "") fun onQueryChange(q: String) { saved["query"] = q // automatically serialised to Bundle } // Navigation args — type-safe via toRoute() (Navigation 2.8+) val productId: String = saved.toRoute <ProductRoute>().productId } // What to save in SavedStateHandle: // ✅ User-typed text (search query, form inputs) // ✅ Navigation arguments (route params) // ✅ Selected tab, scroll position, filter state // What NOT to save: // ❌ Large datasets — Bundle max ~500KB // ❌ Network data — re-fetch after restore // ❌ Room data — Room restores from DB automatically // Test process death: Developer Options → Don't keep activities // Or: adb shell am kill com.your.package
What ViewModel survives vs what SavedStateHandle survives — ViewModel state survives configuration changes (screen rotation, theme change, language change) because it's retained by ViewModelStore, which lives in the Activity's non-configuration instance. But ViewModel does NOT survive process death — when Android kills your app due to memory pressure while it's in the background, the ViewModel is destroyed. When the user returns, a fresh ViewModel is created with empty state. SavedStateHandle bridges this gap: its contents are serialized to the Activity's Bundle and restored by the OS even after process death.
Using SavedStateHandle reactively — inject SavedStateHandle directly into your Hilt ViewModel (Hilt provides it automatically). For reactive state backed by saved state, use val searchQuery: StateFlow<String> = savedStateHandle.getStateFlow("query", ""). This gives you a StateFlow that automatically persists its value — when the user types a search query and the app is killed, the query is restored when the user returns. Write to it with savedStateHandle["query"] = newQuery. Values stored in SavedStateHandle must be Bundle-serializable: primitives, Strings, Parcelables.
Navigation arguments and Bundle size limits — with Compose Navigation 2.8+, savedStateHandle.toRoute<ProductDetailRoute>() gives type-safe access to the navigation arguments that launched this screen. This replaces the fragile string-based savedStateHandle.get<String>("productId") pattern. Critical constraint: the Bundle has a ~500KB size limit enforced by the OS (it's transmitted via Binder IPC). Never store large collections, images, or complex objects in SavedStateHandle. Store only the minimal identifier needed to re-fetch the data — a product ID, not the entire product object. The product data itself lives in Room or an in-memory cache.
Test process death with "Don't keep activities" in Developer Options — it aggressively kills the process when you background the app. If your search query disappears when you come back, you need SavedStateHandle. This is the most common production bug that developers attribute to "rotation" but is actually process death.
32. Scenario: Implement feature flags to gradually roll out a new checkout flow. How do you architect this cleanly?
Feature flags decouple deployment from release. Architecturally they belong at the navigation or use case boundary — never scattered across individual composables.
// :core:flags — feature flag abstraction interface FeatureFlags { val isNewCheckoutEnabled: Boolean val isAiRecommendations: Boolean suspend fun refresh() } // Firebase Remote Config implementation class RemoteFeatureFlags @Inject constructor( private val remote: FirebaseRemoteConfig ) : FeatureFlags { override val isNewCheckoutEnabled get() = remote.getBoolean ("new_checkout") override val isAiRecommendations get() = remote.getBoolean ("ai_recos") override suspend fun refresh() { remote.fetchAndActivate ().await () } } // Navigation-level gate — entire route switched @Composable fun AppNavHost(navController: NavHostController, flags: FeatureFlags) { NavHost(navController, HomeRoute) { homeGraph(navController) if (flags.isNewCheckoutEnabled) newCheckoutGraph(navController) else legacyCheckoutGraph(navController) } } // ONE place decides which checkout — no flag checks in screens // Use case level — algorithm switching class GetRecommendationsUseCase @Inject constructor( private val flags: FeatureFlags, private val aiRepo: AiRecommendationRepo, private val ruleRepo: RuleBasedRepo ) { suspend operator fun invoke(userId: String) = if (flags.isAiRecommendations) aiRepo.get (userId) else ruleRepo.get (userId) } // Test stub — full control per test class TestFeatureFlags( override val isNewCheckoutEnabled: Boolean = false, override val isAiRecommendations: Boolean = false ) : FeatureFlags { override suspend fun refresh() {} }
Feature flags as an abstraction — define a FeatureFlags interface in the domain layer: interface FeatureFlags { val isNewCheckoutEnabled: Boolean; val isRecommendationsV2Enabled: Boolean }. Implement it with FirebaseFeatureFlags in production (reads from Remote Config) and TestFeatureFlags(isNewCheckoutEnabled = true) in tests. Inject it via Hilt everywhere flags are needed. This abstraction means you can test any combination of flag states without touching Firebase, and you can swap the entire flag system (from Firebase to LaunchDarkly to a local JSON file) without changing any business code.
Gate flags at the right architectural layer — where you check the flag determines how easy it is to maintain and remove. The worst pattern: if (featureFlags.isNewCheckout) { NewCheckoutButton() } else { OldCheckoutButton() } scattered across 15 Composables. When the flag is removed, you touch 15 files. The right pattern: gate at the navigation layer (one route check, the entire feature is on or off), or at the use case layer (the flag selects which algorithm or flow runs). One check per feature flag — easy to find, easy to remove when the rollout is complete.
Testing and gradual rollout — in tests, use TestFeatureFlags(isNewCheckoutEnabled = true) — a simple data class implementing the interface with named parameters for each flag, defaulting to false (off). Individual tests opt in explicitly: val viewModel = CheckoutViewModel(featureFlags = TestFeatureFlags(isNewCheckoutEnabled = true)). For gradual rollout in production, Firebase Remote Config lets you control percentages: 10% of users see the new checkout, then 50%, then 100%. The code never changes during rollout — only the Remote Config value changes. When the feature is fully rolled out and stable, remove the flag, the interface method, and both implementations in one clean PR.
The key architectural rule: flags belong at the BOUNDARY between layers, not inside them. Navigation-level = gate entire features. Use case level = switch algorithms. Never inside individual composables. This keeps flag logic centralized and testable.
33. What is the difference between api() and implementation() in Gradle? Why does it matter for multi-module build speed?
api() exposes a dependency transitively to consumers. implementation() keeps it private. Incorrect use of api() causes unnecessary recompilation cascades — the biggest hidden build-time killer in multi-module apps.
// implementation() — private, not exposed to consumers // :core:network/build.gradle.kts dependencies { implementation("com.squareup.retrofit2:retrofit:2.11.0") // Retrofit NOT visible to modules that depend on :core:network // :feature:home cannot use Retrofit directly } // api() — exposed transitively to all consumers dependencies { api(project(":core:common")) // Modules depending on THIS also see :core:common's public API } // Build cascade impact (10 modules depending on :core:network): // Scenario: Retrofit version bump // With api(): // :core:network + :feature:home + :feature:profile + :feature:cart // + :feature:checkout + :feature:orders + :app = ALL 7 recompile // With implementation(): // Only :core:network recompiles — public API unchanged // All consumer modules: SKIP (Gradle cache hit) // Rule: default to implementation() // Only upgrade to api() when: // Your module's PUBLIC functions return types FROM that dependency interface UserDao { fun observeUser(): Flow<UserEntity> // returns UserEntity from Room } // If UserEntity is in :core:database, and UserDao is public API → api() justified // But if you map to domain entity in the module → implementation() is fine
The compilation propagation difference — implementation() declares a private dependency. When that dependency changes, only the declaring module recompiles. Consumers of the declaring module are unaffected — they can't see the dependency's types, so they don't need to know it changed. api() declares a public dependency that leaks through to consumers. When the dependency changes, the declaring module AND all its consumers recompile. In a 10-module chain where every module uses api(), a change to one leaf dependency can force 10 modules to recompile — turning a 10-second build into a 90-second build.
The rule: start with implementation(), promote to api() only when necessary — api() is only justified when your module's public function signatures expose types from the dependency. If :core:network has a public function fun getRetrofit(): Retrofit, then consumers of :core:network need to know what Retrofit is — so Retrofit's module must be declared with api(). But if Retrofit is only used internally (private val retrofit = Retrofit.Builder()...) and only UserApi interfaces are exposed, then implementation() is correct. The heuristic: if consumers would get a "cannot resolve symbol" error without the transitive dependency, use api(). Otherwise, implementation().
Practical audit for an existing project — run ./gradlew :your-module:dependencies and look at the compile classpath. Dependencies appearing transitively when they shouldn't are likely mis-declared as api(). A quick audit of switching all dependencies to implementation() then fixing the compile errors (which tell you exactly which types leaked through) is a reliable way to clean up. In large projects, this single change — replacing api() with implementation() everywhere appropriate — often reduces incremental build times by 30–60% with zero functional changes.
Concrete example: "If :core:network uses api(retrofit) and Retrofit releases a patch, all 10 feature modules recompile even though their code didn't change. With implementation(), only :core:network recompiles. That's 9 unnecessary module compilations eliminated."
34. Scenario: A payment feature must be built by a separate team over 3 months without blocking the main app. How do you architect this?
Parallel team development requires contract-first design — define the API (interface) before implementation. The main team uses a stub; the payment team delivers the real implementation.
// Step 1: Define contract in :feature:payment:api (public, stable) // Both :app and other features can import this safely interface PaymentFeature { fun NavGraphBuilder.paymentGraph (navController: NavController) suspend fun processPayment(orderId: String, method: PaymentMethod): PaymentResult } // Step 2: Checkout team writes a stub (unblocked from day 1) class StubPaymentFeature : PaymentFeature { override fun NavGraphBuilder.paymentGraph (...) { composable("payment") { Text("Payment coming soon") } } override suspend fun processPayment(...) = PaymentResult.Success ("stub-txn-${System.currentTimeMillis()}") } // Step 3: Payment team delivers :feature:payment:impl class PaymentFeatureImpl @Inject constructor( private val paymentRepo: PaymentRepository ) : PaymentFeature { override fun NavGraphBuilder.paymentGraph (...) { composable("payment/{orderId}") { PaymentScreen() } } override suspend fun processPayment(...) = paymentRepo.process (...) } // Step 4: :app swaps stub → real via Hilt @Binds @Module @InstallIn(SingletonComponent::class) abstract class PaymentModule { @Binds abstract fun bindPayment(impl: PaymentFeatureImpl): PaymentFeature // Change this ONE line when payment team delivers: StubPaymentFeature → PaymentFeatureImpl } // Checkout screen — zero changes needed when impl is ready class CheckoutViewModel @Inject constructor( private val payment: PaymentFeature // interface — works with stub OR impl ) : ViewModel()
Contract-first design — define the interface before writing any code — the first week of the project, both teams agree on the PaymentFeature interface: what functions it exposes, what parameters they take, what they return. This contract lives in :payment:api — a tiny module containing only the interface and its associated data classes. The checkout team depends on :payment:api and writes all their code against the interface. The payment team implements it in :payment:impl. Both teams work in parallel from day one — no blocking, no waiting.
The stub pattern for unblocked development — while the payment team builds the real implementation, the checkout team uses a StubPaymentFeature that returns hardcoded successful responses: class StubPaymentFeature : PaymentFeature { override suspend fun processPayment(amount: Money) = PaymentResult.Success(transactionId = "STUB-123") }. Wired via Hilt in debug builds. When the real implementation is ready, one @Binds line change in the DI module swaps the stub for the real implementation — zero changes to checkout code. This is the cleanest possible interface between two teams: agree on the contract, develop independently, integrate in one commit.
:payment:api vs :payment:impl module split — never let consumers import from :payment:impl. The implementation module is a private secret. All public-facing code lives in :payment:api: the interface, request/response data classes, error types. The impl module's build.gradle.kts depends on :payment:api; the checkout module also depends on only :payment:api. Only :app depends on both and wires them together via Hilt. This architecture enables an optional optimization: making :payment:impl a Dynamic Feature Module — users download the payment code only when they first navigate to checkout, reducing initial install size by the size of the payment SDK.
The stub pattern is what makes parallel team development possible. Checkout team writes against PaymentFeature interface from day 1. When payment team delivers, change ONE @Binds line. Zero changes to checkout code. This is how large engineering teams ship independently.
35. What is the Now in Android (NiA) architecture? What patterns can you adopt from it?
Now in Android (NiA) is Google's official open-source reference app. It demonstrates the architecture patterns Google recommends for production apps in 2024-25: multi-module with convention plugins, Hilt, Kotlin Serialization, Kotlin Flows, Compose, and offline-first with Room. Reading the NiA source is the fastest way to see how all these pieces fit together.
// NiA module structure -- follow this pattern for new projects // :app, :core:data, :core:database, :core:network, :core:ui, :core:model // :feature:foryou, :feature:bookmarks, :feature:topic // Convention plugin -- shared build config (NiA pattern) class AndroidFeatureConventionPlugin : Plugin<Project> { override fun apply(target: Project) = with(target) { pluginManager.apply("com.android.library") pluginManager.apply("org.jetbrains.kotlin.android") extensions.configure<LibraryExtension> { compileSdk = 35 defaultConfig.minSdk = 24 } } } // Offline-first repository pattern from NiA class OfflineFirstNewsRepository @Inject constructor( private val dao: NewsDao, private val api: NewsApi ) : NewsRepository { override fun getNews() = dao.getAll() // always reads from Room override suspend fun sync() { dao.upsertAll(api.fetch()) } }
What NiA is and why it matters — Now in Android (github.com/android/nowinandroid) is Google's official open-source Android app that demonstrates best practices for production-quality architecture. It's not a tutorial or sample — it's a real app with real complexity, maintained by Google's Android team. Reading its source code is the fastest way to understand how Clean Architecture, multi-module, Hilt, Compose, and offline-first patterns fit together in a production codebase, rather than seeing each pattern in isolation. Before senior Android interviews, reading at least the architecture overview and one feature module end-to-end is worth several hours of study.
Key patterns NiA demonstrates — the build system uses convention plugins in build-logic/: each feature module's entire build.gradle.kts is 3–5 lines of plugin applications. The data layer is offline-first: Room is the single source of truth, network sync writes to Room, the UI observes Room via Flow. ViewModels expose a single UiState sealed class as StateFlow. Navigation uses type-safe routes with @Serializable objects (Navigation 2.8 pattern). All DI is done with Hilt. There's no LiveData — everything is StateFlow and Flow. KSP is used instead of KAPT for faster annotation processing.
What to take from NiA into your own projects — the convention plugin pattern is immediately applicable to any multi-module project with 3+ modules. The offline-first Room-as-source-of-truth pattern is the correct default for any screen that shows data from the network. The single UiState per screen (avoiding multiple uncoordinated StateFlows) prevents impossible state combinations. The :core:network / :core:database / :core:ui module split is a clean starting point for any new multi-module architecture. You don't need to adopt everything at once — pick the patterns relevant to your current pain points and introduce them incrementally.
"I've studied the Now in Android project" signals seniority immediately. Even better: "We adopted their convention plugin approach — it reduced our build config duplication by 80% across 8 modules." Applying the pattern, not just knowing it, shows real experience.
36. Scenario: Your app is growing. When do you migrate from single-module to multi-module, and what's the migration strategy?
Migrate from single-module to multi-module when one of three signals appears: incremental builds take over 2 minutes, two teams are stepping on each other's code, or you need a feature that only makes sense as a separate module (like an on-demand dynamic feature). Don't migrate because it feels architectural -- migrate because a real pain point justifies the cost.
// Phase 1: extract :core:network and :core:database (lowest risk, high build payoff) // Phase 2: extract :core:ui (shared Compose components) // Phase 3: extract :feature:X one screen at a time // Strangler Fig pattern -- migrate incrementally, never freeze the codebase include(":app") // start: everything here // Week 2: include(":app", ":core:network") // extract networking first // Week 4: include(":app", ":core:network", ":core:database") // Convention plugins -- add before extracting feature modules // Otherwise every new module needs 50 lines of duplicated build config
The three signals that justify migration — multi-module has real setup costs, so only migrate when a real pain point exists. Signal 1: incremental build time exceeds 2 minutes — changing one file and waiting 3 minutes to see the result destroys developer productivity. Signal 2: two or more teams are regularly conflicting on the same code — merge conflicts in shared files indicate the codebase has outgrown single-module organization. Signal 3: you need Dynamic Feature Modules for on-demand delivery — this requires multi-module by definition. Without at least one of these signals, the Gradle complexity overhead is not worth it.
The Strangler Fig extraction strategy — never freeze the codebase for a big-bang migration. The Strangler Fig pattern extracts one module at a time while the app continues shipping new features. Start with the modules that have the clearest boundaries and the highest build cache value: :core:network (Retrofit, OkHttp, interceptors — no UI dependencies, changes rarely), :core:database (Room DAOs, entities — similarly stable). These two modules alone typically cut 30–40% off incremental build times because they compile once and cache. Feature modules come later, after the core infrastructure is stable.
Convention plugins first — the prerequisite step — set up :build-logic with convention plugins before creating the first feature module. Without convention plugins, each new module requires 50 lines of duplicated Gradle configuration. With convention plugins, each module is 3 lines. If you create 5 feature modules before setting up convention plugins, you'll need to refactor 5 build files later — instead of doing it right once upfront. Set up the Gradle infrastructure, validate it with one module extraction, then proceed with confidence. Run tests after every module extraction to catch dependency issues early.
"Enable Gradle caching first — that's free 30-50% improvement in 5 minutes. Modularization is the expensive, weeks-long investment. Show you measure and take the quick wins before committing to the big architectural change." This pragmatism impresses senior interviewers.
37. What is Dependency Inversion vs Dependency Injection — and how do they work together?
DIP is a design principle (the D in SOLID). DI is a technique to implement it. DIP says WHAT to do (depend on abstractions). DI says HOW to do it (provide concrete implementations from outside).
// Dependency Inversion Principle (DIP) // "High-level modules should not depend on low-level modules. // Both should depend on abstractions." // ❌ Violates DIP — ViewModel depends on concrete implementation class UserViewModel { private val repo = UserRepositoryImpl(RetrofitApi(), RoomDao()) // High-level (VM) depends on low-level (Retrofit, Room) — wrong! } // ✅ Follows DIP — depends on abstraction class UserViewModel(private val repo: UserRepository) { // High-level (VM) depends on interface — Retrofit/Room details hidden } // Dependency Injection — the TECHNIQUE that makes DIP work // Method 1: Manual DI (no framework) val api = RetrofitInstance.userApi val repo: UserRepository = UserRepositoryImpl(api) // concrete provided val vm = UserViewModel(repo) // VM gets interface // Method 2: Hilt (automated DI) @HiltViewModel class UserViewModel @Inject constructor( private val repo: UserRepository // Hilt resolves → UserRepositoryImpl ) : ViewModel() // Testing payoff — BOTH principles together: val vm = UserViewModel(FakeUserRepository()) // Possible because of DIP (interface, not concrete class) // Easy because of DI (constructor injection, not internal creation) // Without DIP: DI is impossible (nothing to swap) // Without DI: DIP works in design but wiring is manual and error-prone
Dependency Inversion Principle — the design rule — DIP (the D in SOLID) says: high-level modules should not depend on low-level modules; both should depend on abstractions. In Android terms: the ViewModel (high-level) should not import UserRepositoryImpl, Retrofit, or Room (low-level, concrete). It should depend on UserRepository (an interface — an abstraction). The domain layer defines the interface; the data layer implements it. This inversion of the natural dependency direction is what makes the domain layer independent of implementation details.
Dependency Injection — the technique — DI is the mechanism that satisfies DIP at runtime. Once the ViewModel depends on the UserRepository interface (DIP applied), someone still needs to provide a concrete UserRepositoryImpl. DI is the practice of providing it from outside the class — through the constructor — rather than letting the class create it. Without DI, you'd have to call UserViewModel(UserRepositoryImpl(userDao, userApi)) everywhere. With Hilt, the framework generates that wiring automatically based on your @Provides and @Binds declarations.
They only work together — DIP without DI: you define interfaces correctly (ViewModel depends on UserRepository interface), but wire the object graph manually everywhere — hundreds of lines of factory code that grows unmanageably. DI without DIP: you inject concrete types (UserRepositoryImpl directly into the constructor) — you have a DI framework but the ViewModel is still coupled to the implementation, still untestable with a fake. The combination is what unlocks testability: DIP says "depend on the interface," DI (via Hilt) says "here's the right implementation for this context — production in the app, fake in tests." Interviewers asking this question want to hear that you understand why both are necessary — not just what each acronym stands for.
The power socket analogy: DIP says "use a socket (interface) not a hardwired connection." DI says "the electrician (Hilt) plugs your appliances in." You need both: without DIP, the electrician can't help. Without DI, you wire everything manually — same principle, more work.
38. How do you handle one-time UI events (navigation, snackbar, dialog) in MVVM and MVI?
One-time events must not be stored in StateFlow (deduplication issue) or re-delivered after rotation. The standard patterns are Channel-as-Flow or MVI side effects.
// Problem with StateFlow for events: // StateFlow deduplicates — addToCart("item1") twice → only ONE snackbar // StateFlow replays — after rotation, old navigation event fires again! // Solution 1: Channel (MVVM — recommended) @HiltViewModel class CheckoutViewModel @Inject constructor() : ViewModel() { private val _events = Channel<CheckoutEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow () // expose as Flow fun onOrderPlaced(orderId: String) { viewModelScope.launch { _events.send (CheckoutEvent.NavigateToSuccess (orderId)) } } } sealed class CheckoutEvent { data class NavigateToSuccess(val orderId: String) : CheckoutEvent() data class ShowError(val msg: String) : CheckoutEvent() } // Collect in Composable — LaunchedEffect for one-time collection LaunchedEffect(Unit) { vm.events.collect { event -> when (event) { is CheckoutEvent.NavigateToSuccess -> navController.navigate (SuccessRoute(event.orderId)) is CheckoutEvent.ShowError -> snackbar.showSnackbar (event.msg) } } } // Solution 2: MVI side effects — same Channel pattern, named differently sealed class OrderEffect { // Effect = one-time side effect data class Navigate(val route: Any) : OrderEffect() data class ShowSnackbar(val msg: String) : OrderEffect() object ShowConfirmDialog : OrderEffect() }
Why StateFlow is wrong for one-time events — StateFlow always replays its current value to new collectors. If you store a navigation event in a StateFlow and the screen rotates, the new collector immediately receives the navigation event and the user gets navigated again — a double navigation bug. StateFlow also deduplicates equal values: if the user submits the same form twice, the second error (same message as the first) is not emitted because the value hasn't changed. These two behaviors make StateFlow fundamentally wrong for one-time events like navigation, showing snackbars, or triggering dialogs.
Channel — the correct primitive — a Channel(Channel.BUFFERED) delivers each event exactly once to exactly one collector. The ViewModel sends events: private val _events = Channel<UiEvent>(Channel.BUFFERED); val events = _events.receiveAsFlow(). The Composable collects with a lifecycle-aware effect: LaunchedEffect(Unit) { viewModel.events.collect { event -> when(event) { is NavigateToDetail -> navController.navigate(DetailRoute(event.id)) } } }. Each navigation event is consumed exactly once — rotation doesn't replay it because the Channel doesn't buffer after consumption.
MVI side effects — the naming convention — in MVI architecture, one-time events are called "side effects" or "effects" (distinct from the ongoing State). The pattern is cleanly named: State represents the current UI (rendered continuously), Intent represents user actions (inputs), Effect represents one-time side effects (navigation, showing a dialog, playing a sound). The ViewModel exposes two streams: val state: StateFlow<UiState> for rendering and val effects: Flow<UiEffect> (backed by a Channel) for side effects. This explicit separation prevents the common mistake of trying to put navigation events into StateFlow.
Why Channel over SharedFlow(replay=0) for navigation? If the composable is not yet collected (e.g. initialising), SharedFlow drops the event. Channel BUFFERS it — the navigation fires when the composable starts collecting. Channel.BUFFERED is the safe default for one-time events.
39. Scenario: Your app has multiple flavors (free, pro, enterprise). How do you architect this with Hilt and multi-module?
Product flavors combined with Hilt modules let you swap entire feature implementations per flavor — no if/else checks scattered across code, clean separation at the DI layer.
// build.gradle.kts — define flavors android { flavorDimensions += "tier" productFlavors { create("free") { dimension = "tier" } create("pro") { dimension = "tier" } create("enterprise") { dimension = "tier" } } } // Core interface — same across all flavors interface AnalyticsService { fun track(event: String) } interface ExportService { suspend fun exportToCsv(): Uri } // src/free/java — free flavor implementation class NoOpAnalytics : AnalyticsService { override fun track(e: String) {} } class LockedExportService : ExportService { override suspend fun exportToCsv(): Uri = throw UpgradeRequiredException() } // src/pro/java — pro flavor implementation class FirebaseAnalytics : AnalyticsService { override fun track(e: String) { /* firebase */ } } class CsvExportService : ExportService { override suspend fun exportToCsv() = buildCsv() } // Hilt module — same file, different impl per flavor source set // src/free/java/di/FlavorModule.kt @Module @InstallIn(SingletonComponent::class) abstract class FlavorModule { @Binds abstract fun bindAnalytics(impl: NoOpAnalytics): AnalyticsService @Binds abstract fun bindExport(impl: LockedExportService): ExportService } // src/pro/java/di/FlavorModule.kt — SAME class name, different impl @Module @InstallIn(SingletonComponent::class) abstract class FlavorModule { @Binds abstract fun bindAnalytics(impl: FirebaseAnalytics): AnalyticsService @Binds abstract fun bindExport(impl: CsvExportService): ExportService } // Zero if/else in business code — Hilt injects the right impl per flavor
The core pattern — source set DI modules — define your interfaces in the domain or core layer: interface AnalyticsService, interface ExportService, interface PremiumFeatureGate. Then create flavor-specific implementations: src/free/kotlin/AppModule.kt with @Binds fun bindAnalytics(impl: BasicAnalytics): AnalyticsService, and src/pro/kotlin/AppModule.kt with @Binds fun bindAnalytics(impl: FirebaseAnalytics): AnalyticsService. Both files are named identically — Gradle's source set system compiles exactly one of them depending on which flavor is being built. Hilt generates the correct dependency graph for each flavor automatically.
Zero if-else in business code — the alternative approach — sprinkling if (BuildConfig.FLAVOR == "pro") { ProFeature() } else { FreeFeature() } throughout the codebase — creates unmaintainable code. Every feature comparison is a new if-else. Adding a new flavor means searching for every if-else. Tests must be configured per-flavor. With the source set approach, adding a new flavor (enterprise) means creating src/enterprise/ with its implementations — existing code is untouched. The ViewModel injects AnalyticsService and never knows which flavor is running.
What each flavor typically customizes — analytics (BasicAnalytics for free, Firebase+Amplitude for pro), export capabilities (disabled for free, CSV/PDF for pro, API access for enterprise), feature gates (which screens and flows are accessible), network endpoints (different base URLs or API keys per environment), and crash reporting (none in free, Sentry in pro, custom in enterprise). The consistency principle: all flavors implement the same interfaces — the domain layer is completely flavor-agnostic. Only the DI modules and their implementations differ. This architecture makes it easy to reason about what each flavor can do by reading its AppModule — one file per flavor, one source of truth.
The key insight: same interface name, same Hilt module class name, different source sets. Gradle picks the right source set for the flavor being built. Zero conditional logic in ViewModels or use cases — they just inject ExportService and get the right one for their tier.
40. Scenario: Your app suddenly has to support a new backend API while keeping the old one running for 3 months. How do you architect this transition?
API migration with a compatibility period requires abstracting the API version behind the repository interface — ViewModels and use cases are completely unaffected by the backend change.
// Repository interface — unchanged throughout migration interface UserRepository { suspend fun getUser(id: String): User fun observeUsers(): Flow<List<User>> } // V1 API (current) interface UserApiV1 { @GET("/v1/users/{id}") suspend fun getUser(@Path("id") id: String): UserDtoV1 } // V2 API (new) interface UserApiV2 { @GET("/v2/users/{id}") suspend fun getUser(@Path("id") id: String): UserDtoV2 } // Migration repository — uses feature flag to route calls class UserRepositoryMigrating @Inject constructor( private val v1: UserApiV1, private val v2: UserApiV2, private val flags: FeatureFlags, private val dao: UserDao ) : UserRepository { override suspend fun getUser(id: String): User { return if (flags.isV2ApiEnabled) { v2.getUser (id).toDomain () // v2 mapper } else { v1.getUser (id).toDomain () // v1 mapper } } } // After 3 months — clean up: // 1. Remove v1 API interface and DTO // 2. Replace UserRepositoryMigrating with UserRepositoryV2Impl // 3. Update @Binds to use new impl // 4. Remove feature flag // Zero changes to ViewModel, UseCase, or UI layer // Why this works: // Domain layer (UserRepository interface) is stable // Data layer (impl) absorbs the API change // Presentation layer unaffected — doesn't know about v1 or v2
The repository interface as the stability point — the ViewModel calls userRepository.getUser(id) and returns a domain User entity. The API version is an implementation detail hidden inside the repository. This is where Clean Architecture pays its most visible dividend during migrations: if the ViewModel depended directly on UserApiV1, every screen touching user data would need modification. With a repository interface, only the data layer changes — zero ViewModel modifications, zero domain layer modifications, zero UI changes.
The migration repository with feature flag routing — create a MigrationUserRepository that wraps both UserRepositoryV1Impl and UserRepositoryV2Impl: class MigrationUserRepository(private val v1: UserRepositoryV1Impl, private val v2: UserRepositoryV2Impl, private val flags: FeatureFlags) : UserRepository { override suspend fun getUser(id: String) = if (flags.isApiV2Enabled) v2.getUser(id) else v1.getUser(id) }. Wire it via Hilt. Now control the rollout with a Firebase Remote Config flag: start at 0%, validate in production with real traffic, ramp to 10%, 50%, then 100% over weeks. Monitor error rates at each step — roll back by toggling the flag.
Versioned DTOs, same domain entity — both API versions return user data, but in different shapes. Define UserDtoV1 and UserDtoV2 separately, each with its own mapper to the domain User entity. The mapper hides the shape differences — UserDtoV2 might use fullName while UserDtoV1 used firstName + lastName. The domain entity and ViewModel see only User.name. After the 3-month parallel run, delete UserApiV1, UserDtoV1, UserRepositoryV1Impl, and the MigrationUserRepository. Change one @Binds line to wire UserRepositoryV2Impl directly. The cleanup PR is small and safe.
This is Clean Architecture's core value demonstrated in a real scenario: the domain layer (UserRepository interface) absorbs the business requirement (use users), the data layer absorbs the technical detail (v1 vs v2 API). The presentation layer never knows any of this happened.
41. What is a use case (interactor) — when should you use them and when are they overkill?
A use case encapsulates a single business operation. They're justified for complex logic, multi-repository orchestration, or shared logic across ViewModels. A simple one-liner is overkill.
// ❌ OVERKILL — wraps a single repository call with zero logic class GetUsersUseCase @Inject constructor(private val repo: UserRepository) { suspend operator fun invoke() = repo.getUsers () // zero business logic } // Just call repo.getUsers() directly in ViewModel — no use case needed // ✅ JUSTIFIED — multi-step business operation class PlaceOrderUseCase @Inject constructor( private val cartRepo: CartRepository, private val inventoryRepo: InventoryRepository, private val paymentRepo: PaymentRepository, private val orderRepo: OrderRepository ) { suspend operator fun invoke(cartId: String, method: PaymentMethod): Order { val cart = cartRepo.getCart (cartId) require(cart.items.isNotEmpty ()) { "Cart is empty" } inventoryRepo.reserve (cart.items) // Step 1 val payment = paymentRepo.charge (cart.total (), method) // Step 2 val order = orderRepo.create (cart, payment) // Step 3 cartRepo.clear (cartId) // Step 4 return order } } // ✅ JUSTIFIED — shared across multiple ViewModels class GetUserUseCase(...) { // Used by: ProfileViewModel, CheckoutViewModel, SettingsViewModel // Validation + enrichment logic shared — no duplication } // Decision checklist: // ✅ Complex multi-step business process → use case // ✅ Multiple repositories orchestrated → use case // ✅ Logic shared by 2+ ViewModels → use case // ✅ Business rules needing pure-Kotlin tests → use case // ❌ Single repository.getXxx() call → skip use case
When use cases are justified — a use case earns its existence when it contains logic that would otherwise be duplicated, or logic that is too complex to belong in the ViewModel. Three clear signals: (1) the operation requires coordination between multiple repositories (PlaceOrderUseCase calls orderRepo, inventoryRepo, and paymentRepo); (2) the operation contains business rules that must be validated before execution (ValidateCartUseCase checks cart is not empty, items are in stock, payment method is valid); (3) the same operation is needed by multiple ViewModels (GetUserUseCase used by ProfileViewModel, NotificationViewModel, and WidgetViewModel).
When use cases are overkill — a use case that is literally one line is a code smell: class GetUserUseCase(private val repo: UserRepository) { operator fun invoke(id: String) = repo.getUser(id) }. This adds a class, a file, a Hilt binding, and a test file — for zero added value. Call the repository directly from the ViewModel. The Clean Architecture dogma of "every ViewModel → use case → repository" is not a universal rule — it's a guideline for where complexity belongs. A simple read-only screen that fetches one list has no business logic to extract. Use cases are for business logic, not for indirection.
The operator fun invoke convention and domain purity — use cases should be callable as functions, not objects with named methods. Use operator fun invoke(params) so callers write placeOrderUseCase(cart, payment) not placeOrderUseCase.execute(cart, payment). This feels natural in Kotlin and makes the call site read like a function call without exposing the class structure. Most importantly: use cases must have zero Android imports. No Context, no LiveData, no @Entity. If a use case needs Android, it's a sign it contains presentation or infrastructure logic, not domain logic. Pure Kotlin use cases run in JVM-only unit tests in milliseconds — no emulator, no Robolectric.
Being honest about overkill wins senior interviews: "I use use cases only for complex business logic or shared operations. A GetUsersUseCase that just calls repository.getUsers() adds a class with zero value — I call the repository directly from the ViewModel." Pragmatism over dogmatism.
42. Scenario: During a code review you find a junior dev using LiveData in the domain layer and Room @Entity in the domain model. What's wrong and what do you say?
Both are domain layer violations — the domain must be pure Kotlin with zero Android or framework dependencies. Frame feedback constructively with the WHY, not just "that's wrong."
// ❌ What the junior wrote in domain layer // domain/src/main/kotlin/User.kt import androidx.room.Entity // ❌ Room in domain! import androidx.lifecycle.LiveData // ❌ Android in domain! @Entity(tableName = "users") // ❌ DB annotation on domain entity data class User(val id: String, val name: String) interface UserRepository { fun getUser(id: String): LiveData<User> // ❌ LiveData in domain! } // Why this is wrong: // 1. Domain can't be used in pure Kotlin modules (KMP) — has Android dep // 2. Tests require Android runtime (Robolectric) — not plain JVM // 3. Domain layer should be testable in milliseconds — now it needs emulator // 4. @Entity ties schema decisions (Room) into business model // 5. LiveData lifecycle won't work correctly outside Android // ✅ What it should be // domain/src/main/kotlin/User.kt — pure Kotlin, no imports data class User(val id: String, val name: String) interface UserRepository { fun observeUser(id: String): Flow<User> // Flow from kotlinx-coroutines-core } // kotlinx-coroutines-core is fine in domain — it's pure Kotlin, not Android // Domain build.gradle.kts — purity enforced by module type // plugins { kotlin("jvm") } ← NO android plugin at all // dependencies { kotlinx-coroutines-core only } // Any Android import → build fails immediately — enforced!
@Entity in domain — coupling schema to business logic — @Entity is a Room annotation from androidx.room. Using it on a domain model class creates a direct coupling between the database schema and the business model. The consequences are concrete: if you need to rename a database column, add an index, or change the storage representation of a field, you must also change the domain entity — which propagates changes to every use case, every ViewModel, and every test that uses that entity. The domain should be insulated from these storage decisions. Domain entities are plain Kotlin data classes with no framework annotations.
LiveData in domain — Android framework dependency — LiveData comes from androidx.lifecycle, an Android library. A domain module that imports Android libraries can no longer be a plain Kotlin module — it needs the Android Gradle plugin, an emulator for testing, and it's incompatible with Kotlin Multiplatform. Domain tests that use LiveData must run with Robolectric, adding significant test startup time. The fix is straightforward: use Flow<T> from kotlinx-coroutines-core, which is pure Kotlin, fully multiplatform, and compatible with JVM-only unit tests. Flow has a richer operator set than LiveData anyway.
How to frame the feedback constructively — the junior developer didn't make these choices out of malice — they likely came from tutorials that mixed layers. Lead with the WHY, not the "that's wrong": "The reason we keep the domain layer free of Android imports is so we can run these tests without an emulator — pure JVM tests run in 50ms instead of 5 seconds. It also keeps us KMP-ready if we ever want to share this logic with an iOS app. Let me show you how to separate the Room entity from the domain entity with a mapper function." Enforcement is also an option: the domain module's build.gradle.kts can use plugins { kotlin("jvm") } instead of the Android plugin — any Android import becomes a build error, making the boundary self-enforcing.
The purity test line: "Can I run this module's unit tests with just the JVM — no Android, no emulator?" With Room @Entity and LiveData: no. With pure Kotlin data class and Flow: yes. Make the purity enforcement automatic by using plugins { kotlin("jvm") } — any Android import is a compile error.
43. What is the difference between application module, library module, and dynamic feature module?
An application module (:app) produces an installable APK or AAB -- it has an applicationId and is the entry point. A library module produces an AAR consumed by other modules -- it has a namespace but no applicationId. A dynamic feature module is a library that can be delivered on-demand via Play, reducing install size.
// Application module -- com.android.application plugin plugins { alias(libs.plugins.android.application) } android { defaultConfig { applicationId = "com.example.app" } // only app modules have this } // Library module -- com.android.library plugin plugins { alias(libs.plugins.android.library) } android { namespace = "com.example.core.network" // for R class -- no applicationId } // Dynamic feature module -- com.android.dynamic-feature plugin plugins { id("com.android.dynamic-feature") } android { // inherits applicationId from :app -- declared there } // app/build.gradle.kts must list it: dynamicFeatures += setOf(":feature:ar")
Application module — the single entry point — there is exactly one application module per app. It uses plugins { id("com.android.application") }, declares the applicationId (your app's unique identifier on the Play Store), and produces the installable APK or App Bundle (AAB). It's the only module that knows about all other modules — it wires together all Hilt modules, sets up the root NavHost, and declares the Application class annotated with @HiltAndroidApp. Dependencies flow toward :app: feature modules don't depend on :app, only :app depends on feature modules.
Library module — the building block — library modules use plugins { id("com.android.library") } and produce an AAR (Android Archive) consumed by other modules. They have a namespace (for generating the R class for resources) but no applicationId. All :core:* and :feature:* modules are library modules. A pure Kotlin library (domain layer, utility functions with no Android imports) can use plugins { kotlin("jvm") } instead — no Android plugin needed, faster compilation, and stricter domain layer enforcement.
Dynamic Feature Module — on-demand delivery — a dynamic feature module is a special library that is not included in the base APK. Instead, it's downloaded from the Play Store on-demand when the user first navigates to that feature. This is Google Play Feature Delivery. The benefit: if 40% of users never use the "Advanced Export" feature, those users never download that code — smaller install size, faster first launch. Dynamic Feature Modules have an important constraint: they depend on :app (in reverse of normal direction), which complicates Hilt setup. They're the right choice for large optional features (an in-app camera, an advanced analytics dashboard) but add considerable architecture complexity — only adopt them when install size is a measured user problem.
Dynamic Feature Modules are the answer to "how do you reduce APK size for a large app?" AR navigation, HD maps, professional editing tools can be 20-50MB — downloaded only when first used. Google Play's initial download threshold matters — keeping under it increases install rates significantly.
44. Scenario: Your team is debating whether to use MVI or MVVM for ALL screens. What's your recommendation?
The right answer isn't one or the other — it's using each where it fits. MVVM for simple screens, MVI for complex state machines. Team consistency matters but pragmatism wins over dogmatism.
// MVVM is better when: // ✅ Simple screen: show list, handle empty, handle error // ✅ 1-2 concurrent data sources // ✅ Team is new to Compose — simpler mental model // ✅ Screen state has 2-3 independent pieces class ArticleListViewModel : ViewModel() { val articles = repo.getArticles ().stateIn (...) // simple val isRefreshing = MutableStateFlow(false) } // MVI is better when: // ✅ Complex screen with 5+ state pieces that interact // ✅ Multiple concurrent events (WebSocket + user actions + timer) // ✅ Strict predictability required (finance, healthcare) // ✅ Replay/undo needed — Intent log enables this data class TradingState( val price: Double = 0.0, val quantity: Int = 0, val orderType: OrderType = OrderType.LIMIT, val isSubmitting: Boolean = false, val error: String? = null, val confirmRequired: Boolean = false ) // Recommendation for a team: // ✅ MVVM as default — simpler, less ceremony // ✅ MVI for complex feature screens (checkout, live trading, chat) // ✅ Standardise the EVENT pattern (Channel events for both) // ✅ Define what "complex" means for YOUR team (5+ state vars = MVI) // The overhead of MVI for simple screens: // sealed class UserIntent { object Load : UserIntent() } — 3 lines // fun dispatch(intent: UserIntent) — vs fun load() // Unnecessary for a simple list screen
Don't enforce one pattern everywhere — the debate "MVVM or MVI for all screens" is based on a false premise. The correct answer is: use the pattern appropriate for each screen's complexity. A settings screen with 3 toggle states and a save button has no business using MVI with sealed Intent classes and a reduce function — that's three files of boilerplate for one line of logic. A live order tracking screen with WebSocket updates, polling, user actions, and a cancel button has real consistency risk with multiple independent StateFlows — MVI's single state object and unidirectional flow genuinely help here.
Establishing a team threshold — rather than debating the philosophy, define a practical threshold the whole team agrees on: "if the screen has more than 4 interacting state pieces, or more than 2 concurrent event sources, use MVI. Otherwise, MVVM." This gives everyone a clear decision rule without requiring a case-by-case debate on every new screen. The threshold number matters less than the team having a shared, documented rule they all follow. Consistency within a team reduces cognitive overhead even if the chosen rule isn't theoretically optimal for every case.
Standardize what matters most — the event pattern — regardless of MVVM or MVI, the one thing worth standardizing across all screens is how one-time events are handled. Channel-based effects (for navigation, snackbars, dialogs) should be consistent everywhere — every ViewModel exposes a val effects: Flow<UiEffect> backed by a Channel. This is where accidental differences cause the most confusion. Standardize the event pattern, establish the complexity threshold, and let developers use their judgment within those guardrails. Rigid uniformity at the cost of over-engineering simple screens is a worse outcome than pragmatic variation.
The pragmatic senior answer: "MVVM as the default with a clear upgrade path to MVI when state complexity crosses a threshold. We define that threshold as a team — e.g. 5+ state variables or 3+ concurrent event sources. This gives consistency without dogma."
45. Scenario: A new team member asks "why can't I just put everything in the Activity?" — how do you explain the architecture decisions made so far?
The case against 'put everything in the ViewModel' is separation of concerns: ViewModels should hold UI state and delegate to use cases -- not contain business logic, network calls, or database queries directly. A ViewModel that does everything becomes untestable and violates single responsibility.
class CheckoutViewModel @Inject constructor( private val placeOrderUseCase: PlaceOrderUseCase, // domain layer private val getCartUseCase: GetCartUseCase ) : ViewModel() { val cart = getCartUseCase().stateIn(viewModelScope, SharingStarted.Eagerly, null) fun placeOrder() { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } placeOrderUseCase() // business logic lives in use case, not here .onSuccess { _uiState.update { it.copy(orderPlaced = true) } } .onFailure { _uiState.update { it.copy(error = it.message) } } } } }
Start with the concrete problem — testability — the most compelling argument for a new team member is concrete and immediate: "An Activity that contains all the logic can't be unit tested. Activities require the Android framework to run — you need a physical device or emulator just to run one test. A ViewModel with logic can be tested on the JVM — no device, no emulator, tests run in milliseconds. A use case can be tested even faster. The architecture decision isn't ideology — it's about whether we can have a test suite that gives us confidence in minutes, not hours."
Rotation survival and memory leaks — the second concrete argument: Activities are destroyed and recreated on screen rotation. Any state stored in the Activity is lost. Any work in progress (network call, database query) is lost. If the Activity holds a Context reference to itself in a callback, you get a memory leak. The ViewModel was invented specifically to solve this — it survives rotation, and because it never holds an Activity reference, it can't leak it. A 200-line Activity handling its own network calls has to manage all of this manually, with high probability of subtle bugs that only appear on rotation.
Separation of concerns enables future flexibility — the third argument is about the future: "Right now, this app is one Activity. But as it grows, we'll add more screens, a widget, a Wear OS app, maybe a KMP iOS version. If all logic is in the Activity, none of it is reusable. If logic lives in use cases (pure Kotlin), the same use case runs in the Activity, the widget, the Watch app, and the iOS app. Architecture is about keeping options open. Putting everything in the Activity closes options — the code is permanently coupled to one UI paradigm. Layered architecture keeps those options open with almost zero extra complexity at small scale."
The best architecture explanation: connect each layer to the problem it solves. "ViewModel exists because Activities crash on rotation." "Repository exists because ViewModels shouldn't know if data came from DB or network." "Multi-module exists because build times were 5 minutes." Architecture is solutions, not theory.
46. What is Qualifier in Hilt (@Named vs custom @Qualifier)? When do you need them?
Qualifiers distinguish between multiple bindings of the same type. When Hilt sees two @Provides methods returning the same type, it can't decide which to inject — qualifiers tell it which to use where.
// Problem: two CoroutineDispatchers — which one gets injected? @Provides fun provideIo(): CoroutineDispatcher = Dispatchers.IO @Provides fun provideDefault(): CoroutineDispatcher = Dispatchers.Default // Hilt error: multiple bindings for CoroutineDispatcher! // Solution 1: @Named — built-in string qualifier (simpler) @Provides @Named("IO") fun provideIo(): CoroutineDispatcher = Dispatchers.IO @Provides @Named("Default") fun provideDefault(): CoroutineDispatcher = Dispatchers.Default // Inject with @Named class Repository @Inject constructor( @Named("IO") private val ioDispatcher: CoroutineDispatcher ) // Downside: string "IO" — typos not caught at compile time // Solution 2: Custom @Qualifier — type-safe (preferred) @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher @Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher @Provides @Singleton @IoDispatcher fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO class Repository @Inject constructor( @IoDispatcher private val dispatcher: CoroutineDispatcher ) // In tests — inject TestDispatcher with the same qualifier @Provides @IoDispatcher fun provideTestIo(): CoroutineDispatcher = StandardTestDispatcher()
When you need qualifiers — Hilt requires every binding to be uniquely identifiable. When you have two implementations of the same type — two Retrofit instances (one authenticated, one unauthenticated), or three CoroutineDispatcher instances (IO, Default, Main) — Hilt can't know which one to inject without a qualifier. Without qualifiers, the second binding causes a duplicate binding error at compile time. Qualifiers are Hilt's mechanism for disambiguating multiple implementations of the same type.
@Named vs custom @Qualifier — @Named("IoDispatcher") is built into Dagger and requires no boilerplate — just a string. The risk: a typo like @Named("I0Dispatcher") (zero instead of lowercase O) won't be caught at compile time. Hilt will build successfully and then crash at runtime when it can't find the binding. A custom annotation is the safe alternative: @Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher. A typo in the annotation name is a compile error — caught immediately. The annotation also gets IDE autocomplete, refactoring support, and Find Usages — none of which work for string-based qualifiers.
The dispatcher injection pattern — a concrete best practice — inject CoroutineDispatcher into every class that needs coroutine dispatching: class UserRepositoryImpl @Inject constructor(@IoDispatcher private val ioDispatcher: CoroutineDispatcher). In production, Hilt provides Dispatchers.IO. In tests, the test module replaces it with StandardTestDispatcher(). Every coroutine in every class becomes controllable in tests — advanceUntilIdle() runs them all, advanceTimeBy() skips delays. This single pattern — injectable dispatchers with custom qualifiers — dramatically simplifies coroutine testing across the entire codebase and is considered a Hilt best practice in the official Android documentation.
Injecting CoroutineDispatchers with @Qualifier is a production best practice. @IoDispatcher @Provides fun provideIo() = Dispatchers.IO — in tests, bind TestDispatcher to @IoDispatcher. Now ALL coroutines in ALL classes are controlled by the test scheduler automatically.
47. What is the difference between monorepo single-module and multi-module? When is each appropriate?
In a monorepo single-module setup, all teams work in one codebase with one build output. In a true multi-module monorepo, each module is independently buildable and teams own specific modules. The trade-off is build isolation and team autonomy versus the complexity of inter-module dependency management.
// Single-module monorepo -- one build output, shared codebase my-app/ ├── app/src/main/kotlin/ // everything in one module └── build.gradle.kts // Multi-module monorepo -- separate build outputs per module my-app/ ├── app/ ├── core/network/ ├── core/database/ ├── feature/home/ // team A owns this ├── feature/checkout/ // team B owns this └── build-logic/ // shared convention plugins // Build avoidance: Gradle only rebuilds modules with changed inputs // Change in :feature:home → only :feature:home + :app rebuild // Change in :core:network → all dependent modules rebuild
Single-module monorepo — simplicity at small scale — a monorepo with one Gradle module means one build configuration, one dependency graph, no inter-module communication complexity. All code is in one place, easily searchable, no module boundary friction. This is genuinely appropriate for small teams (2–4 people) and codebases under 50k lines of code. The trade-off emerges as the project grows: any file change potentially invalidates the entire compilation unit. A typo fix in a utility function triggers recompilation of 200 files because Gradle can't know what was actually affected. Build times grow proportionally with codebase size.
Multi-module monorepo — incremental scalability — multi-module splits the compilation unit into independently buildable pieces. When :feature:home changes, only :feature:home and :app recompile — :feature:checkout, :feature:profile, and all :core:* modules serve from cache. This incremental benefit scales: a 20-module project with a change in one leaf module might recompile 2 modules out of 20. On CI, build cache hits for unchanged modules make parallel builds dramatically faster. Team ownership becomes enforceable: the build system prevents :feature:home from depending on :feature:profile — accidental coupling is a compile error.
Core modules are the build bottleneck — the most important insight about multi-module build performance: a change in :core:network triggers recompilation of everything that depends on it — potentially every feature module. This is why you must keep core modules stable. The discipline required: core module APIs should be designed carefully upfront, changed rarely, and backward-compatible when changed. Feature module changes are cheap; core module changes are expensive. This asymmetry should influence your module design: move volatile code to feature modules, keep stable contracts in core modules. :core:network should expose an interface that rarely changes, not implementation details that change frequently.
"I'd start with good package-by-feature structure in a single module. When builds exceed 2 minutes and the team hits 4 developers, I'd extract :core:network and :core:database first — they change rarely and give immediate cache benefits. Feature modules come last when team ownership becomes the bottleneck."
48. Scenario: Your app processes user data. A legal requirement arrives — all user data must be encrypted at rest and PII must be deletable. How do you architect this?
Data privacy requirements should be handled in the data layer — the domain and presentation layers should be completely unaffected. This is Clean Architecture's value in practice.
// Encryption at rest — transparent to all other layers // Option 1: EncryptedSharedPreferences (for small data) @Provides @Singleton fun provideEncryptedPrefs(@ApplicationContext ctx: Context): SharedPreferences = EncryptedSharedPreferences.create (ctx, "secure_prefs", MasterKey.Builder(ctx).setKeyScheme (MasterKey.KeyScheme.AES256_GCM).build (), EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM) // Option 2: SQLCipher / Room with encryption @Provides @Singleton fun provideEncryptedDb(@ApplicationContext ctx: Context): AppDatabase = Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db") . openHelperFactory(SupportFactory("passphrase". toByteArray ())).build () // Domain layer: ZERO changes. Data layer: swapped database builder. // PII deletion — use case handles it class DeleteUserDataUseCase @Inject constructor( private val userRepo: UserRepository, private val ordersRepo: OrderRepository, private val prefsRepo: PreferencesRepository, private val analyticsRepo: AnalyticsRepository ) { suspend operator fun invoke(userId: String): Result<Unit> = runCatching { userRepo.anonymize (userId) // replace PII with "DELETED" ordersRepo.removePersonalData(userId) prefsRepo. clearUser(userId) analyticsRepo. deleteUserData (userId) // Atomic — all or nothing } } // ViewModel calls DeleteUserDataUseCase — unaware of encryption details
Encryption is a data layer concern — domain unchanged — this is one of the clearest demonstrations of Clean Architecture's value. The legal requirement changes how data is stored, not what the business does with it. The UserRepository interface in the domain layer stays identical: suspend fun getUser(id: String): User. The ViewModel and use cases are completely unaffected. Only the data layer changes: the Room database factory is replaced with a SQLCipher-backed encrypted factory, and SharedPreferences is replaced with EncryptedSharedPreferences. Zero changes above the data layer.
Implementation specifics — for Room encryption, use SQLCipher: Room.databaseBuilder(context, AppDatabase::class.java, "encrypted.db").openHelperFactory(SupportFactory(passphrase)).build(). The passphrase is derived from the Android Keystore — the most secure option since the key never leaves the secure hardware. The Room DAO interface is completely unchanged; only the database initialization changes. For SharedPreferences, EncryptedSharedPreferences.create(context, fileName, MasterKey.Builder(context).build(), ...) is a drop-in replacement with identical API. For network data in transit, enforce HTTPS with certificate pinning in OkHttp.
PII deletion — the use case approach — the legal right to erasure requires anonymizing or deleting all PII associated with a user. Model this as a DeleteUserDataUseCase in the domain layer: it orchestrates across all repositories — anonymize the user profile, delete purchase history, remove saved addresses, clear cached tokens. Each repository provides a deleteUserData(userId) method. The use case calls them all in the right order, handles partial failures (if address deletion fails, don't leave the user thinking deletion succeeded), and returns a result. Because this logic lives in the domain layer as a use case, it's testable with fake repositories and completely decoupled from the encryption implementation details.
This demonstrates Clean Architecture's real-world value: a major compliance requirement (GDPR right to erasure) is handled entirely in the data layer. The ViewModel calls DeleteUserDataUseCase — it doesn't know about SQLCipher, EncryptedPreferences, or which tables store PII. Architecture pays its debt.
49. What are the key architectural improvements in Android 2024-25 — KSP, Compose Navigation 2.8, and Hilt updates?
2024-25 has brought significant tooling improvements that affect architecture — KSP replaces KAPT, type-safe Navigation, and Hilt improvements make the stack cleaner and faster.
// 1. KSP — replaces KAPT (30-50% faster annotation processing) // ❌ KAPT (old) // kapt("com.google.dagger:hilt-compiler:2.51") // kapt("androidx.room:room-compiler:2.6.1") // ✅ KSP (new) // ksp("com.google.dagger:hilt-compiler:2.51") // ksp("androidx.room:room-compiler:2.6.1") // Both Hilt and Room fully support KSP since 2024 // 2. Navigation Compose 2.8 — type-safe routes // ❌ Old — string-based, no compile-time safety // navController.navigate("profile/{userId}") // ✅ New — Kotlin Serialization, type-safe @Serializable data class ProfileRoute(val userId: String) navController.navigate (ProfileRoute(userId = "123")) fun NavGraphBuilder.profileGraph () { composable<ProfileRoute> { backStack -> val route = backStack.toRoute <ProfileRoute>() ProfileScreen(userId = route.userId) } } // 3. SavedStateHandle.toRoute() — type-safe nav args in ViewModel @HiltViewModel class ProfileViewModel @Inject constructor(savedState: SavedStateHandle) : ViewModel() { val userId = savedState.toRoute <ProfileRoute>().userId // type-safe! } // 4. Compose BOM 2024.09.00 — stable SharedTransitionLayout // Shared element transitions native in Compose // 5. Kotlin 2.0 + K2 compiler // Better smart casts after suspend calls // Faster compilation with K2
KSP replacing KAPT — the biggest build improvement — KAPT (Kotlin Annotation Processing Tool) was a compatibility shim that compiled Kotlin to Java stubs before running Java annotation processors. KSP (Kotlin Symbol Processing) processes Kotlin directly — 30–60% faster annotation processing for Hilt, Room, Navigation, and Moshi. Hilt, Room, and Navigation all have stable KSP support as of 2024. Migration is a one-time change in each module's build.gradle.kts: replace kapt("com.google.dagger:hilt-compiler:...") with ksp("com.google.dagger:hilt-compiler:..."). For a large multi-module project, this single change often reduces clean build time by 30+ seconds.
Compose Navigation 2.8 type-safe routes — the string-based navigation of earlier Navigation releases was fragile: navController.navigate("detail/$productId") had no compile-time validation. Navigation 2.8 introduces type-safe routes using @Serializable data classes: @Serializable data class DetailRoute(val productId: String). Navigate with navController.navigate(DetailRoute(productId)) — type-safe, refactor-safe, no string parsing. In ViewModels, access navigation arguments with savedStateHandle.toRoute<DetailRoute>() — fully typed, no string keys. This replaces the entire string-based argument system and eliminates a class of runtime crashes from malformed route strings.
Kotlin 2.0 K2 compiler and SharedTransitionLayout — Kotlin 2.0's K2 compiler (stable since 2024) brings improved data flow analysis with better smart casts, more accurate type inference, and measurably faster compilation — particularly for incremental builds in multi-module projects. The upgrade is backward-compatible for most codebases. Compose 1.7 stabilized SharedTransitionLayout — native support for shared element transitions between Composables. Previously, shared element transitions required complex workarounds or third-party libraries. Now: SharedTransitionLayout { AnimatedContent { if (isDetail) DetailScreen() else ListScreen() } }. Combined, these improvements make the 2024-25 toolchain meaningfully better than 2023 for both developer experience and production performance.
Mentioning specific version numbers signals you track the ecosystem: "We migrated from KAPT to KSP in early 2024 — our annotation processing went from 45 seconds to 20 seconds. Navigation 2.8's type-safe routes eliminated an entire class of runtime crashes from string typos." Applied knowledge beats theoretical knowledge.
50. Scenario: You're leading a 6-month architecture review for a 3-year-old Android app with 200K LOC. What's your process?
A 6-month architecture review for a legacy app is a migration, not a rewrite. The goal is to move to a maintainable, testable architecture incrementally -- without pausing feature delivery. Start with the highest-pain areas, establish patterns in a pilot feature, then roll out across the codebase.
// Month 1-2: Audit and stabilise // Add Crashlytics if missing, fix top 3 crashes, add CI if missing // Map dependencies: which classes are used everywhere? Those are your core modules. // Month 3-4: Pilot feature -- establish the target architecture class CheckoutViewModel @Inject constructor( private val placeOrder: PlaceOrderUseCase ) : ViewModel() { /* clean architecture pilot */ } // Month 5-6: Extract :core modules, migrate top 3 features to new pattern // Do not rewrite everything -- use Strangler Fig include(":app", ":core:network", ":core:database", ":feature:checkout")
Months 1-2 — stabilise before you refactor — the single most common mistake in legacy app rewrites is starting refactoring on a system that's actively on fire. Crashes, CI failures, unclear ownership, and unknown dependencies all make refactoring dangerous. Spend the first two months stabilising: instrument crash reporting (Firebase Crashlytics), establish CI with automated tests running on every PR, profile build times to understand where the bottlenecks are, and conduct a dependency audit to understand what architectural violations exist. Create a lightweight architectural decision record (ADR) documenting the current state and the target state. You cannot safely refactor what you don't understand.
Months 3-4 — pilot one feature end-to-end — resist the temptation to architect everything before writing code. Select one feature that touches all layers (data, domain, presentation) and rewrite it end-to-end in the target architecture. This pilot serves three purposes: it proves the architecture works in your specific codebase (theory meets reality), it surfaces integration challenges before they're widespread, and it produces a reference implementation the entire team can learn from. The pilot feature becomes the living documentation of your architecture — more valuable than any README. Conduct a detailed code review of the pilot with the full team before proceeding.
Months 5-6 — incremental extraction with Strangler Fig — never freeze the codebase for a big-bang rewrite. The Strangler Fig pattern: all new features use the new architecture; existing features migrate as they're naturally touched (bug fixes, new requirements). Extract core modules first — :core:network and :core:database have clear boundaries, immediate build time benefits, and affect all features. Measure success quantitatively before and after: build time (incremental, clean), crash-free rate, test coverage percentage, mean time to deploy a change. Present these numbers to leadership — they justify the architectural investment in terms stakeholders understand and validate that the team's effort produced real outcomes.
The strangler fig pattern is the key: "Don't rewrite working code. When we add a new feature to Screen X, we refactor Screen X to MVVM as part of that work. In 6 months, the high-traffic screens are all migrated with zero risk from rewriting." This shows realistic senior-level execution planning.