Back to articles

Interview Prep

Android Architecture Interview Questions — 20 Scenario-Driven Prompts for Senior Roles

The architecture round is where most senior Android interviews are won or lost — not the DSA round, not the Compose round, not even system design. The reason: architecture questions don’t have textbook answers. “What is MVVM?” gets you 30 seconds of safe ground; the next question, “Walk me through how you’d handle the cart state across three checkout screens given eventual server consistency,” is where junior and senior answers diverge dramatically.

This post is 20 of those second-question prompts. Different from my other interview posts — the Top 50 Android post was breadth, the Compose 25 was depth on one topic, this one is scenario-driven. Each question describes a real situation a senior Android engineer faces; the answer is how I’d reason through it in an interview. Memorizing won’t help — the goal is to internalize the trade-offs so you can riff on whatever variant your interviewer throws.

Examples come from a food delivery app domain because it’s rich with architecture-relevant problems: multiple data sources, real-time order tracking, offline-then-sync flows, multi-step checkout, search results that need to update as the user types.


Section 1: State Management & ViewModels (1–5)

1. You’re building a checkout flow with 4 screens (cart → address → payment → review). Each screen needs access to the cart state. How do you structure it?

The trap: reaching for a Hilt singleton “@Singleton class CartManager” that holds checkout state in memory.

The right approach: a scoped ViewModel shared across the four destinations. Compose Navigation supports this via navController.getBackStackEntry("checkout_route") — you scope a ViewModel to the parent navigation graph, then each child screen retrieves the same instance via hiltViewModel(parentEntry). The state lives for the duration of the checkout flow, dies when the user exits checkout, and survives configuration changes within it. SavedStateHandle inside the ViewModel covers process death.

Why this beats a singleton: lifecycle is explicit. The cart state is alive when checkout is active and gone when it’s not — no leaks, no stale data on next entry, no manual reset logic. A singleton would survive the user backing out and starting over, leaking the previous attempt’s state.

2. The user opens the order tracking screen. The order status updates in real time. How do you model this state?

The trap: using StateFlow<OrderStatus> alone and updating it on every WebSocket event. Sounds fine until you realize: what about the order details (items, address, ETA) that don’t change? You don’t want to re-emit the entire OrderState on every status tick.

The right approach: split the model. StateFlow<Order> for the static-ish details (loaded once, refreshed occasionally), StateFlow<OrderStatus> for the high-frequency updates. The UI binds to both. Compose only recomposes the parts that read the changing piece. If you really want a single state object, use derivedStateOf or a combine — but be honest with yourself about which fields change.

This is the broader principle: group state by frequency of change, not by domain proximity. Anything updating multiple times per second deserves its own flow.

3. Your ViewModel has 30 fields and 40 functions. The interviewer asks if that’s a problem.

Yes. Almost always yes. The right pushback: a 30-field ViewModel is doing the work of multiple screens. The fix is structural, not cosmetic.

For the food delivery app, a single “CheckoutViewModel” with 30 fields probably has the cart, address selection, payment selection, validation state, and order submission all in one class. Split into three or four ViewModels by sub-screen, sharing the actually-shared state through a parent scope. The non-shared state lives where it’s used. Each ViewModel becomes testable in isolation.

The structural smell to articulate: when a ViewModel mixes UI state from multiple screens, every UI test needs to set up state for screens it doesn’t care about. That’s the test pain that signals the architectural problem.

4. The screen needs to react to a one-shot event — show a snackbar after order placement succeeds. How do you model it?

The trap: a StateFlow<String?> for the snackbar message, set to non-null on success, set back to null after showing. Re-fires on rotation because StateFlow replays its current value to new collectors.

The right approach: a Channel<UiEvent> exposed as receiveAsFlow(). Each event is consumed exactly once; rotation doesn’t re-deliver. Or, alternatively, encode events as part of state with a “consumed” flag the UI flips after handling — more verbose, easier to test, harder to forget.

The general principle: state describes “what’s true now.” Events describe “something happened.” Different abstractions, different tools. Mixing them is the source of half the “why does my snackbar show twice” bugs.

5. The user is in the middle of typing in a search field. The screen rotates. What should happen?

The text should still be there, the cursor should still be in roughly the right place, and any in-flight search request should not be cancelled and re-fired needlessly.

Mechanism: rememberSaveable for the text field state (survives rotation). The ViewModel holds the search query as a separate StateFlow, updated as the user types, debounced before triggering the search request. Because the ViewModel survives rotation, the in-flight Flow chain (debounce + flatMapLatest) survives too — rotation doesn’t break it. The rotation only re-creates the View layer, which re-subscribes to the ViewModel’s flows.

The senior insight: this is exactly what ViewModel was designed for. Anything that “needs to survive rotation” goes there; anything UI-specific (cursor position, scroll state, expanded/collapsed) stays in rememberSaveable at the composable level.


Section 2: Repository & Data Layer (6–10)

6. You have a screen that needs restaurants from network, the user’s favorites from a local DB, and a flag from feature config. How do you structure the repository?

The trap: one mega-repository with three injected sources, returning a giant combined object.

The right approach: separate repositories by domain ownership, not by data source. RestaurantRepository, FavoritesRepository, FeatureFlagRepository — each owns its domain. The use case (or the ViewModel) combines them via combine:

val restaurantsWithFavorites: Flow<List<RestaurantViewModel>> = combine(
    restaurantRepo.observeNearby(location),
    favoritesRepo.observeFavoriteIds(),
    featureFlagRepo.observe(“show_promo_badges”)
) { restaurants, favoriteIds, showBadges ->
    restaurants.map { it.toViewModel(isFavorite = it.id in favoriteIds, showBadges) }
}

Why this matters: each repository can be tested independently, replaced independently, and reasoned about independently. The combination logic is small and explicit. A mega-repository would couple changes across all three concerns.

7. The repository has a getOrders() function. Should it return List<Order>, Flow<List<Order>>, or both?

Almost always Flow<List<Order>> if Room is the source of truth. Reason: when the underlying tables change (new order placed, status updated), the Flow re-emits. The UI updates without manual refresh. Suspend functions are still right for one-shot reads (loading a single order by ID for the details screen) where you don’t need ongoing observation.

The trap most candidates fall into: returning a snapshot when the data is observable. The user adds an item to their cart on screen A; navigates to screen B which shows the cart count. With suspend, screen B shows stale data unless it manually refreshes. With Flow, it just works.

The senior addition: even when calling Flow-returning DAO functions from a use case, prefer transformation over collection. orderRepo.observeOrders().map { ... } stays reactive; orderRepo.observeOrders().first() doesn’t. Don’t collapse a Flow to a snapshot until the consumption layer.

8. The network call returns 200 OK with a JSON body containing {"error": "Restaurant closed"}. How do you model this?

The hard truth: HTTP status codes don’t map cleanly to domain errors. Some servers always return 200 and indicate failure in the body. Some return 4xx with parseable error bodies. Some return 5xx for transient backend issues vs. permanent ones.

The right approach: a sealed Result type defined at the domain layer, with the data-source layer responsible for mapping HTTP responses (and exceptions) into it.

sealed class FetchResult<out T> {
    data class Success<T>(val data: T) : FetchResult<T>()
    data class BusinessError(val type: ErrorType, val message: String) : FetchResult<Nothing>()
    data class Transient(val cause: Throwable) : FetchResult<Nothing>()
    object NoConnection : FetchResult<Nothing>()
}

enum class ErrorType { RESTAURANT_CLOSED, OUT_OF_STOCK, RATE_LIMITED, INVALID_INPUT }

The repository converts every messy HTTP situation into one of these cases. The ViewModel pattern-matches on the result and produces UI state. The UI never sees an HTTP code, never sees a Throwable directly, never has to decide what 503 means — the data layer made all those decisions.

The general principle: errors are domain concepts. The fact that the server expressed an error as 200-with-body or 422 is an implementation detail of the API. Don’t leak it upward.

9. The same data appears in two screens with different sort orders and filters. Where does the sort/filter logic live?

The trap: putting the sort logic in the repository, then needing two repository functions (getRestaurantsByDistance, getRestaurantsByRating) that diverge over time.

The right approach: repository returns the canonical data; the screen-specific transformations live in the ViewModel or use case. restaurantRepo.observeNearby() returns the unsorted set; HomeViewModel applies user-selected sort; SearchViewModel applies a different one. The repository’s contract is “here are the restaurants in this area” — how the screen presents them is presentation logic.

The line to draw: data layer answers “what data exists.” Use case / ViewModel answers “how this screen wants to see it.” Pushing presentation logic into the data layer creates a combinatorial explosion.

10. The team wants to add a memory cache layer in front of the repository. Where does it go?

The trap: adding a cache field to the repository class itself, mixing pure-data-access logic with caching strategy.

The right approach: extract the network and DB sources to their own classes (RestaurantRemoteDataSource, RestaurantLocalDataSource); the repository orchestrates them with a caching policy. If the in-memory layer needs to be more sophisticated (LRU eviction, TTL), it’s its own class injected into the repository.

The benefit: caching policy becomes testable separately from data access. You can swap a no-op cache for the real one in tests. You can add metrics on cache hit rate. You can change the policy without touching the data sources.

This is the “repository as orchestrator, sources as workers” pattern. Whether you label them “data sources” or some other name, the separation matters more than the label.


Section 3: Use Cases & Domain Layer (11–13)

11. Do you actually need use cases / interactors?

Honest answer: not always. The standard Clean Architecture answer (“yes, always, for separation of concerns”) is dogmatic and produces useless wrappers in many codebases. The right framing in an interview is opinionated and concrete.

Use cases earn their place when:

(a) The same business logic is used by multiple ViewModels. Without a use case, you copy-paste; with one, the logic lives in one place.

(b) The logic is complex enough to deserve its own test surface, separately from any ViewModel. “PlaceOrderUseCase” that validates cart, checks payment, posts to server, updates local DB, and emits a confirmation event — that’s a use case. It earns its existence.

(c) The team genuinely shares a Domain layer across mobile platforms (Kotlin Multiplatform). Use cases as the natural sharing boundary.

Use cases are noise when they wrap a single repository call: operator fun invoke(id: String) = repo.getOrder(id). That’s ceremony for ceremony’s sake. A senior interviewer wants to hear you can defend whether to add the layer, not parrot the “always three layers” pattern.

12. The interviewer asks: “In Clean Architecture, ViewModels are part of the data layer.” Do you agree?

This is a trap question, mostly. Different sources draw the layer boundaries differently. The Google guide puts ViewModel in the UI layer. Uncle Bob’s original Clean Architecture would put it on the boundary between UI and use cases (a presenter). Some teams have ViewModel call use cases call repositories; others have ViewModels call repositories directly.

The senior answer: avoid taking sides on which is “correct.” Talk about what matters: the ViewModel shouldn’t hold framework-specific state from below (no Context, no Views), shouldn’t leak data-source-specific types upward (no Retrofit Response objects, no Cursor), and should be testable without an emulator. Where you draw the layer boundary is naming; what matters is the dependency rule (outer doesn’t know about inner) and the testability.

If pushed for an opinion: I follow the Google pattern (ViewModel in UI layer) for app code because it’s widely understood, with use cases between ViewModel and repository when the use case earns its keep. Pragmatic Clean, not orthodox Clean.

13. You need to combine data from three repositories with three different update frequencies. The naive combine emits too often. How do you fix it?

combine emits whenever any input emits, which can lead to a flood. Three flows updating at different rates trigger a recombination on every emission of any of them.

Fix options:

(1) distinctUntilChanged on the combined output. If only one part of the combined object changed, downstream sees the same value and skips. Fine for cases where the combined object is small enough to compare cheaply.

(2) Throttle or debounce the combined Flow. combinedFlow.debounce(100) means “only emit if 100ms have passed since the last input change.” Useful when rapid input changes don’t need to propagate every step.

(3) Restructure the consumption. If the UI cares about the three pieces independently (one displays a number, another displays a list, another a flag), don’t combine at all — collect each Flow independently in the UI. Each part of the UI re-renders only on its own changes. This is often the cleanest fix.

The senior signal: recognizing that “combine emits too often” isn’t a Flow bug, it’s a sign that the data should be consumed more granularly.


Section 4: Threading, Concurrency & Side Effects (14–16)

14. The ViewModel kicks off a long-running coroutine in init { }. The user navigates away before it completes. What happens?

If the coroutine is launched in viewModelScope, it gets cancelled when the ViewModel is cleared. Which happens when the navigation destination is popped. Good.

If launched in GlobalScope or some custom scope, it leaks. Bad.

The trap: people use GlobalScope for “fire-and-forget” analytics events from the ViewModel. The right tool there is an injected app-wide ApplicationScope (@Singleton CoroutineScope tied to the Application lifecycle, supervised), not GlobalScope. The application scope still survives ViewModel destruction, but it’s testable, mockable, and explicitly bounded.

Senior addition: be skeptical of init-block work in ViewModels at all. It runs whenever the ViewModel is created, which is fine for “load data when this screen opens.” But for one-time initialization (analytics, log lines), init may run multiple times across navigation if the ViewModel is scoped per destination. SavedStateHandle-backed flags help guard one-time work.

15. You see this code in PR review. What do you flag?

viewModelScope.launch {
    val orders = withContext(Dispatchers.IO) { ordersRepo.fetchOrders() }
    val statuses = withContext(Dispatchers.IO) { statusRepo.fetchStatuses() }
    _state.value = UiState.Success(combine(orders, statuses))
}

Two things:

(1) The withContext(Dispatchers.IO) wrapping is probably wrong. If ordersRepo.fetchOrders() is a properly-implemented suspend function (using Retrofit’s suspend support, Room’s suspend support), it already handles its own dispatcher internally. Wrapping it in withContext(Dispatchers.IO) is redundant at best, masking-a-bug at worst (if the implementation is incorrectly main-thread-blocking, you’re hiding the bug).

(2) The two fetches happen sequentially when they could run in parallel. Use async + await:

viewModelScope.launch {
    coroutineScope {
        val ordersDeferred = async { ordersRepo.fetchOrders() }
        val statusesDeferred = async { statusRepo.fetchStatuses() }
        _state.value = UiState.Success(combine(ordersDeferred.await(), statusesDeferred.await()))
    }
}

The coroutineScope { } wrapper is important: if either fetch fails, the other is cancelled and the failure propagates as expected. Without it, raw async can produce confusing exception behavior.

16. Where in the architecture should retry-with-backoff logic live?

This is a great architecture question because it has multiple defensible answers depending on context.

(1) OkHttp interceptor level: retries any failing HTTP request. Universal but coarse — you can’t differentiate between “retry the search query” (yes, automatically, fast) and “retry the place-order call” (no, the user must confirm again).

(2) Repository level: smarter retry per-domain. SearchRepository retries automatically; OrderRepository doesn’t. Keeps retry logic out of the UI but lets each domain have its own policy.

(3) Use case / ViewModel level: most flexibility. The ViewModel decides whether to retry based on UI context. Less reusable, more verbose.

The senior answer is “it depends, here’s how I’d decide”: idempotent reads with stable backoff → OkHttp or repository. Non-idempotent operations (place order, submit payment) → never auto-retry; let the UI prompt the user. Real-time streams (order tracking) → reconnect logic in the WebSocket client itself, not at higher layers.

Avoid the bad answer: “always retry at level X.” That’s how you end up double-charging users.


Section 5: Modularization & Boundaries (17–20)

17. The app is one module and getting hard to maintain. How do you decide modularization boundaries?

The standard answer is “by feature” (feature_search, feature_cart, feature_orders), with shared infrastructure modules (data, ui-components, network). The good answer adds: boundaries should match team boundaries. If two devs work on search and three on cart, those are natural module lines because the build system enforces what code each team can see.

The bad answer: modularize by layer (data module, domain module, presentation module). This sounds clean but makes every feature change touch every module. You haven’t reduced coupling; you’ve hidden it.

The senior addition: include a navigation module separate from feature modules. Each feature exposes a route and a Composable; the navigation module wires them together. This keeps feature modules from depending on each other — checkout doesn’t need to know that the order details screen exists, just that it can navigate("orderDetails/{id}").

18. Feature module A needs to navigate to a screen in feature module B. How without coupling A to B?

Same pattern as above, with concrete mechanics. Feature A doesn’t reference Feature B’s code at all. Instead, the app module (which depends on both) wires up the navigation graph, and Feature A calls a navigation interface or uses a deep-link-style route string.

// In :feature:checkout
interface CheckoutNavigator {
    fun navigateToOrderDetails(orderId: String)
}

class CheckoutViewModel @Inject constructor(
    private val navigator: CheckoutNavigator
    // Hilt provides the implementation, which lives in the :app module
) : ViewModel() { ... }

// In :app
class CheckoutNavigatorImpl @Inject constructor(
    private val navController: NavHostController
) : CheckoutNavigator {
    override fun navigateToOrderDetails(orderId: String) {
        navController.navigate(“orderDetails/$orderId”)
    }
}

Compile-time decoupling. Feature A compiles without Feature B existing. The wiring lives in app, where it belongs.

19. The team wants to ship Compose adoption gradually. The legacy app uses XML/Views. How do you architect the transition?

The right approach is screen-by-screen migration, not file-by-file. New screens land directly in Compose; existing screens stay in XML until they’re due for major work, then they’re re-implemented (not converted). The hybrid case — a Fragment with both Compose and Views — uses ComposeView embedded in XML or AndroidView embedded in Compose, but treat these as transition aids, not a destination.

The architectural piece: ViewModels are framework-agnostic. The same ViewModel can back an XML Fragment and a Compose screen. So the migration risk is contained to the View layer; the data layer doesn’t need to change. This is the strongest argument for the MVVM-style separation in the first place.

The pitfalls to flag: don’t mix Compose and XML in the same screen unless you have to. The interop bridge has performance costs and complicates testing. Don’t try to write a “both XML and Compose” component library — pick one.

20. Two months into a project, you realize the architecture choices were wrong. The team is shipping features. How do you handle the rewrite question?

You don’t rewrite. You incrementally redirect.

The realistic answer most senior engineers give: identify the one or two most painful architectural decisions, design the future-state pattern, and apply it on every new feature plus opportunistically on touched code. New cart code uses the new pattern; old order-history code stays on the old pattern until someone needs to change it for product reasons.

This is hard because it means living with inconsistency. Teams hate inconsistency. But a 3-month rewrite freeze is worse than 6 months of two patterns coexisting.

Concrete techniques: enforce the new pattern with lint rules so PRs in old code aren’t forced to migrate, but new files must use the new pattern. Document the migration plan publicly so anyone touching old code knows what they’re inheriting. Track migration progress as a metric, not a deadline.

The senior signal: you’re comfortable with imperfect coexistence. You don’t propose burning down to start over. Architecture is something you evolve, not something you re-build.


How These Show Up in Real Loops

Architecture rounds at most senior Android loops are 45–60 minutes, with 4–7 questions in this style. The interviewer rarely sticks to a script — your answer to question 1 generates the natural follow-ups for question 2. Strong candidates feel like they’re having a conversation; weak candidates feel like they’re being quizzed.

The signals interviewers grade on:

(1) Specificity. “I’d use a repository” is junior. “I’d separate the network and DB into data sources, with the repository orchestrating between them and a sealed Result type for error categories” is senior. Concrete patterns, not vocabulary.

(2) Trade-off awareness. Every choice has a cost. Senior candidates name the cost while choosing. “StateFlow because dedup matters, accepting that I lose the one-shot event semantics — I’ll model events separately as a Channel.”

(3) Pragmatism over orthodoxy. Use cases yes-or-no, three layers vs. two, MVVM vs. MVI — none of these has a right answer in absolute. Senior candidates pick a side and defend the choice, including its weaknesses. “I’ve seen Clean Architecture done dogmatically, and it produces a lot of useless wrappers. I’d add layers when they earn their existence.”

(4) Lived experience. “In my last project, we tried X and ran into Y, so we moved to Z” is the strongest possible answer. It demonstrates that you’ve actually shipped these patterns and felt their failure modes. Memorized answers can’t fake this.


Closing

The trick to the architecture round isn’t having the right opinion. It’s being able to defend an opinion in a conversation that gets progressively more specific. Every “walk me through how you’d structure X” turns into “what about when Y goes wrong,” which turns into “but you said Z earlier — how does that fit?” Strong answers thread through.

If you’ve shipped real Android code at scale, you have the material for these answers — you just need to articulate the trade-offs you made along the way. If you haven’t, no amount of memorization fully substitutes. Build something with a real architecture, hit its limits, refactor, and the answers become natural.

That’s the third interview prep post wrapped: breadth (Top 50), depth on Compose (25), and now scenario-driven architecture. Three different angles on the same exam.

Happy coding!

6 views · 0 comments

Comments (0)

No comments yet. Be the first to share your thoughts.