← All topics
Engineering

Architecture & Patterns

MVVM/MVI, Clean Architecture, repositories, dependency injection, modularization, design patterns, and structuring an app that scales and is testable.

37 questions 6 junior23 mid8 senior

Mid and senior interviews lean heavily on architecture. It is where interviewers see whether you can build something that scales, tests, and survives change, not just make one screen work. Expect open-ended “how would you structure…?” discussions where there’s no single right answer, only well-argued trade-offs.

A simple study path

Begin with ViewModel, UI state, repositories, and dependency injection. Then practice explaining one feature from UI to database or network. Clean Architecture, modularization, and detailed DI scopes make more sense after that basic data flow is clear.

What gets tested

  • Presentation patterns - MVC → MVP → MVVM → MVI, and why the field evolved; unidirectional data flow.
  • Clean Architecture - layers (UI/domain/data), the dependency rule, use cases, per-layer models & mapping.
  • Data layer - repository pattern, single source of truth, offline-first (NetworkBoundResource), caching, Paging 3.
  • Dependency injection - DI vs service locator, Hilt/Dagger vs Koin, components & scoping, assisted injection, dispatcher injection.
  • Design patterns - Observer, Factory, Builder, Singleton, Strategy, Adapter/Decorator, Facade - with real Android examples.
  • Modularization - by feature vs layer, api/impl splits, inter-feature navigation, build-speed and encapsulation wins.
  • State & events - modeling immutable UiState, one-off events, SavedStateHandle, error handling across layers.
  • Quality - SOLID, coupling/cohesion, the test pyramid, fakes vs mocks, ViewModel testing, anti-patterns.

How interviewers ask

Lots of “walk me through how you’d structure this feature”, comparison questions (MVVM vs MVI, Hilt vs Koin, fakes vs mocks), and “what’s wrong with this design?” They reward two things at once: knowing the patterns, and judgment about when not to apply them - naming the trade-off (“I’d skip the domain layer here because…”) is what separates senior answers.

Prep tip: be ready to design a feature end-to-end out loud - layers, data flow, DI, testing - and to defend why. Always state the trade-off; “it depends, and here’s on what” is the senior signal.

Start here

Core ideas you should be able to explain in plain language.

Core concepts

Explain the Builder pattern. Is it still needed in Kotlin?
Junior #design-patterns#builder#kotlin

The Builder pattern constructs a complex object step by step, avoiding telescoping constructors (many overloads) and making optional parameters readable.

// Java: classic builder
Notification n = new NotificationCompat.Builder(context, channelId)
    .setContentTitle("Hi")
    .setContentText("Body")
    .setSmallIcon(R.drawable.ic)
    .setAutoCancel(true)
    .build();

Where it appears in Android: NotificationCompat.Builder, AlertDialog.Builder, Retrofit.Builder, OkHttpClient.Builder, Room.databaseBuilder, WorkRequest.Builder, Intent (chained putExtra). These predate Kotlin or come from Java APIs.

Is it still needed in Kotlin? Often not - Kotlin’s default and named arguments replace most builders:

data class RequestConfig(
    val url: String,
    val timeout: Long = 30_000,
    val retries: Int = 3,
    val headers: Map<String, String> = emptyMap(),
)
RequestConfig(url = "...", retries = 5)   // no builder needed

For more builder-like ergonomics, Kotlin uses:

  • apply { } to configure an object fluently.
  • Type-safe DSL builders - a lambda with receiver (buildString { }, Modifier chains, Gradle Kotlin DSL) - the idiomatic Kotlin “builder.”

When a builder still earns its place in Kotlin:

  • Java interop - your API is consumed from Java (no default args there).
  • Step-by-step validation or enforcing a build order / required-before-optional sequencing.
  • Mirroring an established API style for familiarity.
Explain the Facade pattern and how it relates to the Repository.
Junior #design-patterns#facade#repository

A Facade provides a simple, unified interface over a complex subsystem, hiding its internal parts from callers.

// Facade over several subsystems
class MediaFacade(
    private val downloader: Downloader,
    private val decoder: Decoder,
    private val cache: MediaCache,
) {
    suspend fun play(url: String) {          // one simple call...
        val bytes = cache.get(url) ?: downloader.fetch(url).also { cache.put(url, it) }
        val media = decoder.decode(bytes)    // ...hides downloader + decoder + cache
        player.start(media)
    }
}

The caller uses play(url) and never touches the downloader, decoder, or cache directly.

Why use it:

  • Simplifies usage - clients deal with one entry point instead of orchestrating many classes.
  • Decouples clients from subsystem internals - you can restructure the subsystem without breaking callers.
  • Reduces coupling and centralizes a workflow.

How it relates to the Repository: a Repository is essentially a Facade over data sources - it hides the network client, database, cache, and the coordination logic behind a clean API (observeUser()), so the ViewModel doesn’t know whether data came from Room or Retrofit. Many Android “manager”/“controller” classes are facades too.

Other Android examples: a SessionManager wrapping token storage + refresh + auth headers; an AnalyticsFacade over multiple analytics SDKs; Retrofit itself is a facade over OkHttp + converters + call adapters.

Caution: a facade can grow into a God object if it accumulates too many responsibilities - keep it focused on simplifying access, not doing everything.

Explain the Observer pattern and where it appears in Android.
Junior #design-patterns#observer#reactive

The Observer pattern defines a one-to-many dependency: a subject maintains a list of observers and notifies them automatically when its state changes. It decouples the producer of data from its consumers.

// The essence: subscribe, get notified on change
interface Observer<T> { fun onChanged(value: T) }
class Subject<T>(initial: T) {
    private val observers = mutableListOf<Observer<T>>()
    var value: T = initial
        set(v) { field = v; observers.forEach { it.onChanged(v) } }
    fun observe(o: Observer<T>) { observers += o }
}

Where it’s everywhere in Android:

  • LiveData - observe and get lifecycle-aware updates.
  • Flow / StateFlow / SharedFlow - the coroutine-based reactive streams; collect is observing.
  • Compose state - reading a State subscribes the composable; writes notify readers (recomposition).
  • RecyclerView.AdapterDataObserver, click listeners, ViewTreeObserver, LifecycleObserver.
  • RxJava Observable/Observer - the pattern in its named form.

Why it matters architecturally: it’s the backbone of reactive, UDF apps - the UI observes state from the ViewModel and updates automatically, instead of the ViewModel reaching into the UI. This inverts the dependency (UI depends on data, not vice versa).

Trade-offs to mention:

  • Lifecycle leaks - observers not unregistered (or not lifecycle-aware) leak or update dead UI. LiveData/repeatOnLifecycle solve this.
  • Notification storms / ordering - too many fine-grained updates can cause churn (hence distinctUntilChanged, conflation, derivedStateOf).
What are coupling and cohesion, and why do they matter?
Junior #design-principles#coupling#cohesion

Two measures of code quality that good architecture optimizes in opposite directions: low coupling, high cohesion.

Coupling - how much one module depends on another. Low (loose) coupling is the goal: modules interact through small, stable interfaces, so a change in one doesn’t ripple into many others.

  • Tightly coupled: a ViewModel directly instantiating RetrofitClient and RoomDatabase - changing either breaks the ViewModel.
  • Loosely coupled: the ViewModel depends on a Repository interface, injected. Swap the implementation freely.

Cohesion - how focused a module is; how strongly its parts relate to a single purpose. High cohesion is the goal: a class does one well-defined job.

  • Low cohesion: a Utils class with networking, date formatting, and bitmap helpers thrown together.
  • High cohesion: a DateFormatter that only formats dates; a FeedRepository that only handles feed data.

Why they matter:

  • Maintainability - loosely coupled, highly cohesive code is easier to change: edits stay local, and each class is easy to understand.
  • Testability - low coupling lets you inject fakes; high cohesion means small, focused units to test.
  • Reusability - focused modules are reusable; tangled ones aren’t.

How Android practices achieve them:

  • DI + interfaces → low coupling (depend on abstractions).
  • Single Responsibility / layering → high cohesion (each class/layer one job).
  • Modularization → enforces boundaries (low coupling between features).
  • UDF → the UI depends on state, not on the ViewModel’s internals.

These two are the why behind SOLID, Clean Architecture, and DI - interviewers like seeing you connect the principle to the practice.

What is dependency injection, and why use it on Android?
Junior #dependency-injection#testability

Dependency injection (DI) means a class receives its dependencies from outside rather than creating them itself. “Inversion of control” - something else (a framework or the caller) is responsible for constructing and wiring objects.

// ❌ Without DI: class creates and is coupled to concrete dependencies
class UserViewModel {
    private val repo = UserRepository(RetrofitClient.create(), AppDatabase.dao())
}

// ✅ With DI: dependencies injected; class depends on abstractions
class UserViewModel(private val repo: UserRepository)

Why it matters:

  • Testability - inject a fake/mock repository in tests instead of hitting the real network/DB. This is the #1 reason.
  • Decoupling - a class depends on an interface, not a concrete implementation; swap implementations (debug vs prod, different backends) without changing the class.
  • Single responsibility - classes focus on using dependencies, not constructing them (and their dependencies, and their dependencies…).
  • Lifecycle & scoping - a DI framework can provide singletons, per-Activity, or per-ViewModel instances correctly.

On Android specifically:

  • Manual DI works but becomes painful as the graph grows (constructing a ViewModel might require a repo, which needs an API, a DB, an OkHttp client, …).
  • Hilt (built on Dagger) generates this wiring at compile time - type-safe, no reflection - and integrates with Android components (Activity, ViewModel, WorkManager).
  • Koin is a lighter, runtime service locator-style alternative (simpler, but resolution errors surface at runtime).

Forms of DI: constructor injection (preferred - explicit, testable), field injection (for framework-created objects like Activities), and method injection.

What is the Repository pattern, and what problem does it solve?
Junior #repository#data-layer#abstraction

A Repository mediates between the rest of the app and the data sources (network, database, cache, DataStore). It exposes a clean, domain-oriented API and hides where the data comes from.

class UserRepository(
    private val api: UserApi,
    private val dao: UserDao,
) {
    // The caller doesn't know or care this comes from cache + network
    fun observeUser(id: String): Flow<User> = dao.observe(id)
        .onStart { refreshFromNetwork(id) }

    private suspend fun refreshFromNetwork(id: String) {
        runCatching { api.fetch(id) }.onSuccess { dao.upsert(it.toEntity()) }
    }
}

What it solves:

  • Single source of truth - the repository decides the caching/refresh policy (e.g. DB as source of truth, network refreshes it). Callers just observe.
  • Abstraction - ViewModels depend on the repository, not on Retrofit or Room. Swapping the network library or adding a cache doesn’t ripple into the UI.
  • Testability - fake the repository (or its data sources) in ViewModel tests.
  • Centralized logic - retry, mapping DTO→domain, combining sources, and offline behavior live in one place, not scattered across ViewModels.

Design choices:

  • Repositories typically expose domain models, mapping from DTOs (network) and entities (DB) at the boundary.
  • Define the repository as an interface in the domain layer; implement it in the data layer (dependency inversion) so the domain doesn’t depend on data details.
  • One repository per data type/feature (UserRepository, FeedRepository), not one giant “DataRepository.”
  • Keep business logic out of the repository - it does data orchestration; complex rules belong in use cases.

Use it in practice

Common implementation choices, debugging, and trade-offs.

Core concepts

Explain Clean Architecture on Android. What are the layers and the dependency rule?
Mid #clean-architecture#layers#separation

Clean Architecture separates code by responsibility. The useful part is not the diagram or the number of layers. It is keeping business rules independent from Android UI and storage details.

On Android this typically maps to three layers (Google’s recommended architecture):

  • Data layer - repositories and their data sources (network, database, cache). Owns how data is fetched/stored. Exposes data to the domain/UI.
  • Domain layer (optional) - pure business logic: use cases and domain models. No Android dependencies - plain Kotlin, fully testable. Defines repository interfaces.
  • UI (presentation) layer - ViewModels + Compose/Views. Holds UI state, reacts to user input, observes data.
UI  ──depends on──▶  Domain  ◀──depends on──  Data
(ViewModel)          (UseCase,                (Repository impl,
                      interfaces)              network, db)

The dependency rule in practice: a domain layer can define a UserRepository interface and the data layer can implement it. Business logic then knows it can load a user, but does not know whether the data came from Room, Retrofit, or a fake used in a test.

Why teams use it:

  • Testability - domain logic is pure Kotlin, tested without Android.
  • Replaceability - change a data source without rewriting the UI.
  • Separation of concerns - each layer has one reason to change.

Keep it practical:

  • The domain layer is optional - for simple screens, ViewModel → Repository is fine; add use cases when business logic is reused across ViewModels or gets complex.
  • Don’t over-engineer: mapping models across three layers and a use case per call can be overkill for a CRUD app. Match the architecture to the app’s complexity.
  • Separate models can protect layers from each other’s changes, but mapping every small object through four representations is not automatically better.
Explain the Adapter and Decorator patterns with Android examples.
Mid #design-patterns#adapter#decorator

Two structural patterns that are easy to confuse.

Adapter - converts one interface into another the client expects. It wraps an incompatible type to make it usable.

// Adapt a domain list to what RecyclerView expects
class UserAdapter(val users: List<User>) : RecyclerView.Adapter<UserVH>() { ... }

Android examples: RecyclerView.Adapter (the name says it - adapts data to view-holders), PagerAdapter, wrapping a third-party SDK’s interface behind your own (AnalyticsClient interface adapting Firebase/Amplitude), or a Retrofit CallAdapter. Use it to make incompatible interfaces work together, especially to wrap libraries you don’t control behind your own abstraction (an anti-corruption layer).

Decorator - adds behavior to an object dynamically by wrapping it in another object with the same interface, without changing the original.

interface DataSource { suspend fun load(key: String): String }

class CachingDataSource(private val wrapped: DataSource) : DataSource {
    private val cache = mutableMapOf<String, String>()
    override suspend fun load(key: String) =
        cache.getOrPut(key) { wrapped.load(key) }   // adds caching, same interface
}

class LoggingDataSource(private val wrapped: DataSource) : DataSource {
    override suspend fun load(key: String): String {
        Log.d("DS", "load $key"); return wrapped.load(key)
    }
}

Android examples: OkHttp Interceptors (each wraps the chain, adding logging/auth/caching), ContextWrapper (and ContextThemeWrapper), input stream wrappers (BufferedInputStream). You can stack decorators (Logging(Caching(real))) to compose behavior.

The distinction:

  • Adapter = change the interface (make B usable as A).
  • Decorator = same interface, add responsibilities (wrap to enhance).
Explain the Factory pattern and where you use it on Android.
Mid #design-patterns#factory

A Factory centralizes object creation, hiding the construction logic and the concrete type behind a method. Callers ask the factory for an object instead of calling a constructor directly.

// Factory method: decide the concrete type from input
object PaymentProcessorFactory {
    fun create(type: PaymentType): PaymentProcessor = when (type) {
        PaymentType.CARD   -> CardProcessor()
        PaymentType.UPI    -> UpiProcessor()
        PaymentType.WALLET -> WalletProcessor()
    }
}

Why use it:

  • Encapsulates creation - complex/conditional construction lives in one place, not scattered across call sites.
  • Decouples callers from concrete classes (they depend on the PaymentProcessor interface).
  • Open/Closed - add a new type by extending the factory, not editing every caller.

Where it appears in Android:

  • ViewModelProvider.Factory - the common example. ViewModels need constructor args (a repository), but the framework creates them, so you provide a factory that knows how to build it:
class FeedVMFactory(private val repo: FeedRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(c: Class<T>): T = FeedViewModel(repo) as T
}

(Hilt’s @HiltViewModel generates this for you.)

  • Fragment.instantiate / newInstance pattern, RecyclerView.ViewHolder creation in onCreateViewHolder, LayoutInflater.Factory, Retrofit/OkHttp builders internally, and DI @Provides methods are factories.

Variants: Factory Method (a method returns a type), Abstract Factory (a family of related objects), and DI frameworks are essentially generalized factories.

Explain the SOLID principles with Android examples.
Mid #solid#design-principles#clean-code

Five object-oriented design principles for maintainable code:

S - Single Responsibility. A class should have one reason to change.

Android: a ViewModel manages UI state; it shouldn’t also parse JSON or do networking. A God-Activity doing UI + networking + persistence violates this.

O - Open/Closed. Open for extension, closed for modification.

Android: add a new RecyclerView view type or a new PaymentMethod by adding a class, not editing a giant when everywhere. A sealed hierarchy + polymorphism extends behavior without rewriting existing code.

L - Liskov Substitution. Subtypes must be usable wherever the base type is, without breaking expectations.

Android: a FakeRepository must honor the Repository contract so it can replace the real one in tests. A subclass that throws on a method the base supports breaks LSP.

I - Interface Segregation. Prefer small, focused interfaces over fat ones.

Android: don’t force a class to implement a 10-method Callback; split into OnClick, OnLongClick. Clients depend only on what they use.

D - Dependency Inversion. Depend on abstractions, not concretions; high-level modules shouldn’t depend on low-level details.

Android: the ViewModel depends on a UserRepository interface, not RetrofitUserRepository. This is exactly what DI (Hilt) and Clean Architecture’s “domain defines interfaces, data implements them” enforce.

Why this matters: SOLID underpins why we use repositories, interfaces, DI, and layered architecture. The strongest answers tie each principle to a concrete Android decision (the examples above), not just recite definitions.

Explain the Strategy pattern with an Android example.
Mid #design-patterns#strategy

The Strategy pattern defines a family of interchangeable algorithms behind a common interface, so you can swap behavior at runtime without changing the code that uses it.

fun interface SortStrategy {
    fun sort(items: List<Post>): List<Post>
}

val byDate    = SortStrategy { it.sortedByDescending(Post::date) }
val byPopular = SortStrategy { it.sortedByDescending(Post::likes) }

class FeedViewModel(private var strategy: SortStrategy = byDate) {
    fun setStrategy(s: SortStrategy) { strategy = s }
    fun display(posts: List<Post>) = strategy.sort(posts)   // behavior swappable
}

Why use it:

  • Open/Closed - add a new strategy (a new sort/validation/formatting rule) without touching existing code or growing a giant when.
  • Runtime flexibility - switch algorithms based on user choice, config, or A/B flags.
  • Testable - each strategy is isolated and unit-testable.

Where it shows up in Android:

  • RecyclerView.LayoutManager - LinearLayoutManager / GridLayoutManager are interchangeable layout strategies.
  • Interpolator (animations), ItemAnimator, DiffUtil.ItemCallback.
  • Image-loading, caching, or retry policies injected into a repository.
  • Validation strategies, sort/filter options, payment processors, ad providers behind an interface.
  • In Kotlin it’s often just a function type / fun interface passed in - a lightweight strategy without ceremony.

Relation to DI: injecting different implementations of an interface is the Strategy pattern applied via dependency injection (debug vs prod logger, fake vs real repo).

Hilt/Dagger vs Koin - what's the trade-off?
Mid #hilt#dagger#koin#dependency-injection

The core distinction: Dagger/Hilt resolve the graph at compile time; Koin resolves it at runtime.

Dagger / Hilt - compile-time, code-generated DI.

  • Type-safe - missing/duplicate bindings fail the build, not in production.
  • No reflection → fast at runtime, good for large graphs.
  • ✅ Hilt adds Android lifecycle components/scopes out of the box.
  • Steeper learning curve, more annotations, and build-time cost (annotation processing / KSP).
  • ❌ Cryptic Dagger error messages.

Koin - runtime service locator (a DSL that registers and resolves dependencies).

  • Simple and Kotlin-idiomatic - a readable DSL, minimal boilerplate, no codegen, fast builds.
  • ✅ Easy to learn; great for small/medium apps and KMP.
  • Errors surface at runtime - a missing dependency crashes when first requested, not at compile time.
  • ❌ Resolution has some runtime overhead (historically reflection-ish; improved over versions), and less compile-time safety.

How to choose (the balanced interview answer):

  • Large, multi-module, performance-sensitive apps / teams that value compile-time safetyHilt (Google’s recommended default on Android).
  • Smaller apps, rapid prototyping, KMP, or teams prioritizing simplicity and build speedKoin.

Note: Koin is technically closer to a service locator than “true” DI, and that distinction (compile-time safety vs runtime flexibility) is the real heart of the question - not which is “better.”

How do you design an app that works offline?
Mid #offline-first#caching#single-source-of-truth#repository

The core principle: the local database is the single source of truth. The UI always reads from the database; the network only updates the database. The app works offline by default, and network is an enhancement.

UI ──observes──▶ Room (source of truth) ◀──writes── Repository ◀──fetches── Network

The classic flow (NetworkBoundResource pattern):

  1. UI observes a Room Flow → shows cached data immediately (even offline).
  2. Repository decides whether to refresh (stale? forced?).
  3. If refreshing, fetch from network → write into Room.
  4. Room emits the new data → UI updates automatically. The network result never goes straight to the UI.
fun observeArticles(): Flow<List<Article>> = flow {
    emitAll(dao.observeArticles())           // 1. always from DB
}.onStart {
    runCatching { val fresh = api.getArticles(); dao.upsertAll(fresh.map { it.toEntity() }) }
        .onFailure { /* offline: UI still has cached data */ }   // 2-4
}

Key design decisions interviewers probe:

  • Source of truth = DB, not the network response. This is what makes it consistent and offline-capable.
  • Freshness policy - cache-then-network, TTL-based invalidation, or pull-to-refresh forcing a fetch.
  • Writes / sync - queue local mutations (likes, edits) with a status flag, do optimistic UI, and sync to the server when online (often via WorkManager with a network constraint); reconcile conflicts (last-write-wins, version vectors, or server authority).
  • Pagination - Paging 3 + RemoteMediator implements offline-first paging: pages are written to Room, the UI pages from Room.
  • Conflict resolution and partial failure handling are the senior-level details.

Why it’s better than fetch-on-demand: instant loads from cache, resilience to flaky networks, consistent UI, and less redundant fetching.

How do you implement a Singleton in Kotlin, and what are the pitfalls?
Mid #design-patterns#singleton#kotlin

A Singleton ensures a class has one instance with a global access point. In Kotlin it’s trivial - object gives you a thread-safe, lazily-initialized singleton:

object AnalyticsTracker {
    fun track(event: String) { /* ... */ }
}
AnalyticsTracker.track("open")   // single instance, created on first use

The compiler handles thread-safe lazy init - no double-checked-locking boilerplate like Java.

Common pitfalls:

  1. Holding a Context/View leaks it. An object lives for the whole process. If it stores an Activity context, that Activity can never be GC’d. Store applicationContext only, or don’t hold context at all.
    object Bad { lateinit var ctx: Context }   // if assigned an Activity → permanent leak
  2. Global mutable state - singletons holding mutable state create hidden coupling, make code hard to test (shared state bleeds across tests), and cause race conditions if not synchronized.
  3. Testability - a hard-coded object dependency can’t be swapped for a fake. This is the big one: prefer DI with @Singleton scope over a manual object, so the single instance is provided and replaceable in tests.
  4. Initialization order / parameters - an object can’t take constructor parameters; if it needs config, you end up with an init(context) method and ordering hazards.

The recommended approach: use a normal class and let Hilt/Dagger provide it as @Singleton. You get one instance and testability/injectability - the benefits without the global-state/leak downsides.

How do you model UI state well? (single state object vs multiple flows, sealed vs data class)
Mid #state#ui-state#sealed-class

Two common approaches, each with a place:

1. Single immutable data class of nullable/boolean fields - flexible; can represent overlapping conditions (loading while showing stale content).

data class FeedUiState(
    val isLoading: Boolean = false,
    val items: List<Post> = emptyList(),
    val errorMessage: String? = null,
    val isRefreshing: Boolean = false,
)

2. Sealed hierarchy of mutually-exclusive states - clearer when the screen is in exactly one state at a time, with exhaustive when.

sealed interface FeedUiState {
    data object Loading : FeedUiState
    data class Success(val items: List<Post>, val refreshing: Boolean) : FeedUiState
    data class Error(val message: String) : FeedUiState
}

How to choose:

  • States are truly exclusive (can’t be loading and error at once) → sealed. Forces you to handle every case.
  • States overlap (refreshing while content is visible, partial errors) → a single data class with flags is more honest than contorting a sealed hierarchy.
  • A common hybrid: a data class whose fields include a sealed content: ContentState.

Principles regardless of shape:

  • Immutable - expose a single StateFlow<UiState>; update with copy() / update {}. Never let the UI mutate it.
  • Single source of truth - one state object the UI renders, not five separate StateFlows that can drift out of sync.
  • Derive, don’t duplicate - compute showEmptyState from existing fields rather than storing a redundant flag that can desync.
  • Separate one-off events (navigation, snackbars) from state so they don’t replay on rotation.
How does Paging 3 fit into an Android app's architecture?
Mid #paging#architecture#offline-first

Paging 3 is the Jetpack solution for incrementally loading large lists, integrated across all three layers.

The pieces:

  • PagingSource - loads one page from a single source (e.g. network only). Defines how to fetch a page and the keys for next/prev.
  • RemoteMediator - coordinates network + database for offline-first paging: it fetches pages from the network and writes them into Room, while a Room-backed PagingSource serves the UI from the DB.
  • Pager - config (page size, prefetch) that produces a Flow<PagingData<T>>.
  • PagingData - a stream of paged items the UI consumes.

Layered flow (network + DB, the recommended setup):

UI (LazyColumn / PagingDataAdapter)
   ▲  Flow<PagingData>
ViewModel:  Pager(config, remoteMediator) { db.dao().pagingSource() }
                                   │ writes pages
Data:   RemoteMediator ── fetches ──▶ Network,  ── stores ──▶ Room (source of truth)
val items: Flow<PagingData<Article>> = Pager(
    config = PagingConfig(pageSize = 20),
    remoteMediator = ArticleRemoteMediator(api, db),
) { db.articleDao().pagingSource() }
    .flow
    .cachedIn(viewModelScope)     // survive config changes

What Paging handles for you: page requests on scroll, prefetch distance, deduplication, placeholders, retries, and exposing LoadState (loading/error for refresh/append/prepend) so the UI can show spinners/retry footers. UI side: collectAsLazyPagingItems() (Compose) or PagingDataAdapter + DiffUtil (Views).

Why architecturally clean:

  • Single source of truth - with RemoteMediator, the DB is the truth; the UI always pages from Room → offline-first for free.
  • cachedIn(scope) keeps paged data across recreation so scroll position/data isn’t lost on rotation.
  • Each layer keeps its role: data fetches/stores, ViewModel configures the Pager, UI renders PagingData.
How should a ViewModel represent UI state and one-time events?
Mid #events#state#sharedflow#udf

The problem: state is persistent and re-emitted (a StateFlow replays its current value on rotation), but events like “navigate to detail” or “show snackbar” should happen exactly once. Putting an event in StateFlow causes it to re-fire on configuration change (navigation loops, duplicate snackbars).

The common approaches:

1. SharedFlow / Channel with replay = 0 - events are delivered once to active collectors, not replayed.

private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()   // each event consumed once

fun onSave() = viewModelScope.launch {
    repo.save(); _events.send(UiEvent.NavigateBack)
}

A Channel guarantees each event goes to a single consumer and suspends if no one’s collecting (events buffer rather than drop) - often preferred over SharedFlow(replay=0) which can drop events emitted with no active collector.

2. State-based events (the modern recommendation from some Google guidance) - model the event as state that the UI consumes and tells the ViewModel to clear:

data class UiState(val navigateToId: String? = null)
// UI: LaunchedEffect(state.navigateToId) { id -> navigate(id); vm.consumedNavigation() }

This keeps a single source of truth and is process-death safe, at the cost of a “consume” callback.

The collection side matters: collect events with lifecycle awareness (repeatOnLifecycle(STARTED) / collectAsStateWithLifecycle) so an event isn’t delivered to a backgrounded UI and lost.

What to avoid:

  • SingleLiveEvent / “event wrapper” hacks - historically used, now discouraged (fragile, doesn’t compose well).
  • Putting transient events in StateFlow - they replay on rotation.

Important nuance: there’s genuine debate here. Channel/SharedFlow(replay=0) is the pragmatic, widely-used answer; the “events as state you consume” approach is more UDF-pure and process-death safe. Be able to argue both.

How should errors move through an app's layers?
Mid #error-handling#result#sealed-class

Rather than letting exceptions leak everywhere, model expected failures as values that flow through the layers and end as UI state.

A domain Result wrapper (your own sealed type or Kotlin’s Result):

sealed interface DataResult<out T> {
    data class Success<T>(val data: T) : DataResult<T>
    data class Failure(val error: AppError) : DataResult<Nothing>
}

sealed interface AppError {
    data object Network : AppError
    data object Unauthorized : AppError
    data class Unknown(val cause: Throwable) : AppError
}

Repository converts exceptions → typed results at the boundary:

suspend fun getUser(id: String): DataResult<User> = try {
    DataResult.Success(api.getUser(id).toDomain())
} catch (e: IOException) { DataResult.Failure(AppError.Network) }
  catch (e: HttpException) {
      DataResult.Failure(if (e.code() == 401) AppError.Unauthorized else AppError.Unknown(e))
  }

ViewModel maps the result into UI state:

when (val r = getUser(id)) {
    is DataResult.Success -> _state.update { it.copy(user = r.data) }
    is DataResult.Failure -> _state.update { it.copy(error = r.error.toMessage()) }
}

Principles:

  • Distinguish expected vs unexpected failures. Expected (network down, validation, 404) → modeled as Result/sealed errors and shown to the user. Unexpected (programming bugs) → let them crash/report; don’t swallow.
  • Translate at the boundary - convert framework exceptions (IOException, HttpException, SQLException) into domain errors in the data layer so upper layers don’t depend on Retrofit/Room types.
  • Exhaustive handling - a sealed AppError forces the UI to handle each case (retry, re-login, generic message).
  • For Flow, surface errors via a result-emitting flow or the catch operator mapping to an error state - never an unhandled throw in collect.
  • Never catch CancellationException in a blanket catch - rethrow it.
How would you architect feature flags / remote config?
Mid #feature-flags#remote-config#architecture

Feature flags let you toggle features without shipping a new build - for gradual rollouts, A/B tests, kill switches, and per-segment targeting.

Architecture - wrap the source behind your own abstraction:

interface FeatureFlags {
    fun isEnabled(flag: Flag): Boolean
    fun <T> value(flag: Flag, default: T): T
}

enum class Flag(val key: String, val default: Boolean) {
    NEW_CHECKOUT("new_checkout", false),
    DARK_MODE_V2("dark_mode_v2", false),
}

Implement it over Firebase Remote Config (or LaunchDarkly, Statsig, your own backend). The rest of the app depends on the FeatureFlags interface, not the vendor SDK.

Why the abstraction matters:

  • Decoupling / swappability - switch providers without touching feature code (anti-corruption layer).
  • Testability - inject a fake FeatureFlags to test both branches.
  • Type safety - an enum/sealed set of flags beats scattered magic strings.

Design considerations:

  • Fetch & cache - remote config is fetched async and cached locally; provide sensible defaults so the app works on first launch / offline. Don’t block startup on a fetch.
  • Consistency within a session - usually snapshot values at app start / screen entry so a flag doesn’t flip mid-flow; apply new values on next launch.
  • Kill switch - flags let you disable a broken feature server-side without a release - pair with a forced refresh for emergencies.
  • Clean up stale flags - old flags rot; track and remove them.
  • A/B testing - flags carry experiment assignments; log exposure to analytics for analysis.
  • Layering - the flag check usually lives in the domain/data layer (or a config module), surfaced to the UI via state, not scattered if checks everywhere.
MVC vs MVP vs MVVM - how did Android presentation patterns evolve?
Mid #mvp#mvvm#mvc#presentation

All separate UI from logic; they differ in how the logic talks to the view.

MVC (Model-View-Controller) - on Android, the Activity/Fragment often ended up as both View and Controller (“Massive View Controller”). Poor separation; hard to test because logic was tangled with framework classes.

MVP (Model-View-Presenter):

  • The View (Activity/Fragment) implements a View interface and is passive.
  • The Presenter holds the logic, calls back into the view through that interface (view.showLoading(), view.showError()).
  • ✅ Testable (mock the view interface), clear separation.
  • Boilerplate - a View interface with many methods per screen; the Presenter holds a reference to the view, so you must detach it (onDestroy) to avoid leaks; doesn’t survive config changes by itself.

MVVM (Model-View-ViewModel):

  • The ViewModel exposes observable state (StateFlow/LiveData); it does not reference the view.
  • The View observes state and renders it (reactive, UDF).
  • ✅ No view reference → no leak, survives config changes (Jetpack ViewModel), less boilerplate, works naturally with Compose/data binding.
  • ✅ The current recommended pattern (often refined into MVI).

The key shift: MVP pushes to the view via an interface (imperative, two-way coupling); MVVM has the view pull/observe state (reactive, one-way). MVVM’s lack of a view reference is what fixes MVP’s leak and lifecycle pain.

MVP:  Presenter ──calls──▶ View (interface)      [imperative push]
MVVM: View ──observes──▶ ViewModel (state)        [reactive pull / UDF]
MVVM vs MVI - when would you pick one over the other?
Mid #architecture#mvi#mvvm#state

Both put a state holder between UI and data; they differ in how state changes flow.

MVVM - the ViewModel exposes several observable properties; the UI observes them and calls methods to mutate them. Simple and familiar, but state can become fragmented across multiple LiveData/StateFlow fields that can drift out of sync.

MVI - there’s a single immutable UiState, and the UI sends intents/events that the ViewModel reduces into the next state. Strictly unidirectional: Intent → reduce → new State → render.

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)

fun onIntent(intent: Intent) = when (intent) {
    is Intent.Load -> reduce { copy(isLoading = true) }
    // ...
}

Pick MVI when: the screen has complex, interdependent state; you want every UI state reproducible from a single object (great for testing and time-travel debugging); or a team needs a strict, predictable pattern.

Pick MVVM when: the screen is simple - MVI’s boilerplate (intents, reducers, single state) isn’t worth it for a form with two fields.

Senior-level nuance to raise: they’re not opposites. A clean “MVVM with a single immutable StateFlow<UiState> and event functions” is effectively MVI-lite. What interviewers actually care about is unidirectional data flow and a single source of truth, not the acronym. Also mention how you model one-off events (navigation, toasts) separately from state so they don’t replay on rotation.

Walk me through how you'd structure a new feature end to end.
Mid #architecture#practical#design

A common open-ended interview question. Structure the answer by layers + data flow, and mention testing and trade-offs. Example: a “Saved articles” feature.

1. Data layer

  • Models: ArticleDto (network), ArticleEntity (Room), Article (domain) with mappers.
  • Data sources: ArticleApi (Retrofit), ArticleDao (Room).
  • Repository: ArticleRepository interface (domain) + impl (data). Exposes observeSaved(): Flow<List<Article>> from Room (single source of truth), with network refresh writing into Room (offline-first).

2. Domain layer (if warranted)

  • ToggleSaveArticleUseCase, GetSavedArticlesUseCase - only if logic is reused/complex; otherwise the ViewModel calls the repository directly.

3. UI layer

  • SavedViewModel exposes StateFlow<SavedUiState> (immutable state: loading/items/error) via stateIn(WhileSubscribed(5000)); handles events (onToggleSave); emits one-off events (snackbar) on a Channel.
  • SavedScreen (Compose) collects state with collectAsStateWithLifecycle(), renders, sends events up (UDF).

4. Wiring

  • Hilt provides the API, DAO, repository (@Binds interface→impl), scoped appropriately (@Singleton for DB/network, @HiltViewModel for the VM).
  • Navigation destination/route; nav args via SavedStateHandle.

5. Cross-cutting

  • Error handling → typed results mapped to UI state.
  • Testing → unit-test the ViewModel (fake repo + test dispatcher), DAO (in-memory Room), mappers; a Compose UI test for the critical flow.
  • Paging if the list is large (Paging 3 + RemoteMediator).

Then state the trade-offs: “I’d skip the domain layer and separate models if it’s simple, and add them if logic is shared or the API is messy - matching the architecture to the feature’s complexity.”

SavedScreen ──events──▶ SavedViewModel ──▶ UseCase(opt) ──▶ Repository
   ▲ state                                                    │
   └──────────────── StateFlow<UiState> ◀── Room (SoT) ◀── Network

Why this answer lands: it shows you think in layers, UDF, single source of truth, DI, and testing, and that you apply judgment about how much architecture the feature actually needs.

What are common Android architecture anti-patterns?
Mid #anti-patterns#code-smells#clean-code

The ones interviewers love to hear you call out:

God Activity/Fragment - an Activity doing UI, networking, persistence, and business logic. Violates SRP, untestable, unmaintainable. Fix: move logic to ViewModel/use cases/repositories; keep the UI thin.

God ViewModel - a 1000-line ViewModel handling many unrelated features. Fix: split by responsibility, extract use cases.

Leaking Context/View in singletons, ViewModels, static fields, or long-running coroutines. Fix: app context only, lifecycle scoping, weak refs.

Business logic in the UI - validation, formatting, or decisions in composables/Activities. Fix: push into ViewModel/domain; keep UI a function of state.

Mutable shared state without a single source of truth - multiple components caching/mutating the same data, drifting out of sync. Fix: one owner (repository/DB), observe it.

Two-way / circular data flow - UI mutating ViewModel state directly, or ViewModel referencing the View. Fix: UDF (state down, events up); expose read-only state.

Overusing GlobalScope - unscoped coroutines that leak and aren’t cancelled. Fix: lifecycle scopes.

Event bus everywhere (EventBus, LocalBroadcastManager) - implicit, hard-to-trace global messaging. Fix: explicit Flow/callbacks, scoped state.

Over-engineering - three model layers + a use case per trivial call + five modules for a tiny app. Fix: match architecture to complexity; YAGNI.

Stringly-typed everything - string keys for navigation/args, magic strings. Fix: type-safe routes, sealed types, constants.

Mock-heavy tests mirroring implementation - brittle, break on refactor. Fix: prefer fakes, test behavior.

The meta-point: most anti-patterns are violations of separation of concerns, single source of truth, UDF, or lifecycle correctness - or the opposite sin, over-engineering. Naming the underlying principle is what impresses.

What caching strategies would you use in an Android app?
Mid #caching#performance#data-layer

Caching is layered; pick per data type and freshness need.

Cache tiers (fastest → most durable):

  • In-memory - a MutableStateFlow/LruCache in a repository or singleton. Fastest, lost on process death, bounded by size. Good for hot data within a session.
  • Disk / database - Room (structured), DataStore (key-value), files. Survives process death; the basis of offline-first (DB as single source of truth).
  • HTTP cache - OkHttp’s disk cache honoring Cache-Control/ETag for network responses.
  • Image cache - Coil/Glide’s memory + disk LRU.

Read strategies:

  • Cache-then-network - show cached data instantly, fetch in background, update. Best UX for feeds.
  • Cache-aside (lazy) - check cache; on miss, fetch and populate.
  • Network-first with cache fallback - fresh when possible, cache when offline.
  • Read-through - the cache layer fetches on miss transparently.

Invalidation (the hard part - “two hard things in CS”):

  • TTL / expiry - store a timestamp; refetch when stale.
  • ETag / Last-Modified - conditional requests; server returns 304 Not Modified to save bandwidth.
  • Event/push-based - invalidate on a known mutation (user edited data) or a server push.
  • Manual - pull-to-refresh forces a fetch.

Decisions to make:

  • Single source of truth - write network results into the DB and have the UI observe the DB, rather than caching in multiple places that drift.
  • Eviction - bound caches (LruCache, Room cleanup) so they don’t grow unbounded.
  • Consistency vs freshness vs cost - name the trade-off: a longer TTL saves data/battery but risks staleness.
  • Stale-while-revalidate - serve stale immediately, refresh in the background.
What is a UseCase (Interactor), and when do you actually need one?
Mid #usecase#domain-layer#clean-architecture

A UseCase (a.k.a. Interactor) encapsulates a single piece of business logic in the domain layer. It typically depends on repositories and is consumed by ViewModels. Convention: name it as a verb and expose a single invoke operator.

class GetVisibleFeedUseCase(
    private val feedRepo: FeedRepository,
    private val settingsRepo: SettingsRepository,
) {
    operator fun invoke(): Flow<List<Post>> =
        combine(feedRepo.observeFeed(), settingsRepo.blockedAuthors()) { posts, blocked ->
            posts.filterNot { it.author in blocked }   // the business rule
        }
}

// In the ViewModel:
val feed = getVisibleFeed().stateIn(...)

When you NEED one:

  • Logic reused across multiple ViewModels - put it in one place instead of duplicating.
  • Combining multiple repositories / non-trivial rules - orchestration that doesn’t belong in a repository (which does data access) or a ViewModel (which does UI state).
  • Complex domains where you want pure, independently testable business logic with no Android deps.

When you DON’T (the pragmatic point interviewers reward):

  • Pass-through use cases that just call repository.getX() add a layer for no value - boilerplate. If a ViewModel can call the repository directly with no extra logic, skip the use case.
  • Simple CRUD apps rarely need a full domain layer.

Design conventions:

  • One use case = one responsibility (single public method, often operator fun invoke).
  • No Android dependencies - pure Kotlin, trivially unit-tested.
  • Inject the dispatcher if it does CPU work (withContext(defaultDispatcher)), keeping it off the main thread and testable.
What is SavedStateHandle, and how does it fit the architecture?
Mid #savedstatehandle#viewmodel#state

SavedStateHandle is a key-value map injected into a ViewModel that survives both configuration changes (like the ViewModel) and process death (unlike the ViewModel). It’s the architectural answer to “small UI state that must outlive everything.”

Two main jobs:

1. Receive navigation arguments - Hilt/Navigation populate it from the back stack, so a ViewModel reads its args without the UI passing them in:

@HiltViewModel
class DetailViewModel @Inject constructor(
    handle: SavedStateHandle,
    repo: ItemRepository,
) : ViewModel() {
    private val itemId: String = handle["itemId"]!!   // nav arg
    val item = repo.observe(itemId).stateIn(...)
}

2. Persist transient UI state across process death - query text, selected tab, scroll target:

val query: StateFlow<String> = handle.getStateFlow("query", "")
fun setQuery(q: String) { handle["query"] = q }

Where it fits:

  • It bridges the gap the ViewModel can’t cover (process death). The ViewModel handles config changes; SavedStateHandle extends that to process death for the few keys that matter.
  • It replaces manual onSaveInstanceState plumbing in the Activity/Fragment - the state lives in the ViewModel where the logic is, not in the view.
  • Values must be Bundle-able (primitives, Parcelable) and kept small - it’s for identifiers and UI state, not large data (re-fetch big data from the repository on restore).

Why it’s preferred over assisted injection for nav args: Navigation already serializes args into the saved state, so Hilt can populate SavedStateHandle automatically - no custom @AssistedFactory needed.

What is Unidirectional Data Flow (UDF), and why is it the foundation of modern Android architecture?
Mid #udf#state#mvi#architecture

Unidirectional Data Flow means state flows down and events flow up - in one direction, forming a loop:

        ┌──────────── state ────────────┐
        ▼                               │
       UI  ──── events/intents ──▶  ViewModel ──▶ (repository / use case)

                                  produces new state
  • The ViewModel owns the state (a single, immutable UiState) and exposes it as a read-only StateFlow.
  • The UI is a function of that state - it renders whatever the state says.
  • The UI sends events up (button clicks, text input) as method calls/intents; it never mutates state directly.
  • The ViewModel processes the event, produces a new immutable state, and the cycle repeats.
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()   // down (read-only)

fun onRefresh() {                                       // up (event)
    viewModelScope.launch { _state.update { it.copy(loading = true) } }
}

Why it’s foundational:

  • Single source of truth - state lives in one place; the UI can’t drift out of sync.
  • Predictable & debuggable - every UI state is reproducible from one object; you can log/replay state transitions.
  • Testable - feed events, assert on emitted states; no UI needed.
  • Thread-safe updates via immutable copy() + atomic update {}.
  • It’s the principle behind MVI, Compose (UI = f(state)), and Google’s recommended architecture - the acronym matters less than the one-directional discipline.

Related practices: model one-off events (navigation, snackbars) separately (e.g. SharedFlow) so they don’t replay on rotation; keep UiState immutable.

Why should you inject coroutine dispatchers instead of hardcoding them?
Mid #coroutines#dispatchers#testing#dependency-injection

Hardcoding Dispatchers.IO/Default couples your code to real threads, which makes it non-deterministic in tests. Injecting dispatchers makes threading configurable and testable.

The problem with hardcoding:

class Repo(private val api: Api) {
    suspend fun load() = withContext(Dispatchers.IO) { api.fetch() }  // ❌ real IO in tests
}

In tests you can’t control this - runTest’s virtual clock doesn’t govern a real Dispatchers.IO, so timing is unpredictable and tests can be flaky.

The fix - inject the dispatcher:

class Repo(
    private val api: Api,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
    suspend fun load() = withContext(ioDispatcher) { api.fetch() }
}

// Test: pass a TestDispatcher
val repo = Repo(fakeApi, UnconfinedTestDispatcher())

Provide them via DI with qualifiers so the right one is injected everywhere:

@Qualifier annotation class IoDispatcher
@Qualifier annotation class DefaultDispatcher

@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO

A common pattern is a DispatcherProvider interface (io, default, main) injected into repositories/use cases, with a test implementation returning a single TestDispatcher.

Benefits:

  • Deterministic tests - runTest controls the virtual clock; advanceUntilIdle() works; no flakiness.
  • Flexibility - swap dispatchers per environment without touching logic.
  • Honors structured concurrency - viewModelScope already uses Main; you only switch for blocking/CPU work, and now that switch is testable.

Optional deep dives

Internals and broader design questions to study after the core material.

Core concepts

How do you architect a Kotlin Multiplatform (KMP) app? What's shared and what isn't?
Senior #kmp#multiplatform#architecture

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Kotlin Multiplatform lets you share business logic across Android, iOS (and more) while keeping UI native (or shared via Compose Multiplatform).

What’s typically shared (commonMain):

  • Data layer - repositories, networking (Ktor), local storage (SQLDelight / Room KMP), DTOs, mappers.
  • Domain layer - use cases, business rules, domain models.
  • Presentation logic - ViewModels/state holders (with libraries like Decompose, Voyager, or KMP-ViewModel) and StateFlow-based state.
  • Shared coroutines/Flow code, serialization (kotlinx.serialization).

What stays platform-specific:

  • UI - Jetpack Compose on Android, SwiftUI on iOS (or Compose Multiplatform to share UI too).
  • Platform APIs - camera, sensors, permissions, push, secure storage - accessed via the expect/actual mechanism.
// commonMain
expect class PlatformContext
expect fun httpClientEngine(): HttpClientEngine

// androidMain / iosMain provide the `actual` implementations

Key architecture decisions interviewers probe:

  • expect/actual for platform differences - declare the contract in common, implement per platform.
  • Source sets - commonMain, androidMain, iosMain; common code can’t touch Android/iOS APIs directly.
  • DI - Koin is popular for KMP (Hilt is Android-only); or constructor DI in common code.
  • How much to share - sharing the data + domain + presentation layers maximizes reuse with the least friction; sharing UI (Compose Multiplatform) is increasingly viable but more involved on iOS.
  • iOS interop - shared code is exposed to Swift via an Obj-C/Swift framework; suspend/Flow need bridging (SKIE, callbacks) for ergonomic Swift consumption.

Trade-offs: shared logic and consistency vs. tooling maturity, iOS interop friction, and a steeper build setup. Sweet spot for many teams: share logic, keep UI native.

How do you choose the right dependency injection scope?
Senior #dependency-injection#hilt#scoping

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

A scope controls how long a single instance lives and how widely it’s shared. With Hilt:

ScopeOne instance perUse for
@SingletonapplicationDB, Retrofit, OkHttp, app-wide repos
@ActivityRetainedScopedsurvives config changeshared across an Activity + its ViewModels
@ViewModelScopeda ViewModeluse cases/helpers tied to one screen’s VM
@ActivityScopedan ActivityActivity-bound helpers
@FragmentScopeda Fragmentfragment-bound helpers
(unscoped)every injectionstateless, cheap objects

Matching scope to lifetime is the whole game:

Over-scoping (e.g. @Singleton on everything):

  • Memory leaks / bloat - objects live forever even when only needed briefly.
  • Stale state - a singleton holding screen-specific or user-specific state persists across screens/logins when it shouldn’t (e.g. caching the wrong user’s data after re-login).
  • Hidden coupling and harder reasoning about lifecycle.

Under-scoping (unscoped where you needed sharing):

  • Multiple instances when you expected one - e.g. two ViewModels each get a different “session cache,” so they don’t share data.
  • Wasted work - recreating expensive objects (an OkHttp client) on every injection.

Guidance:

  • Expensive, stateless, app-wide (network/DB clients) → @Singleton.
  • Stateless, cheap → leave unscoped (a new instance is fine and avoids retention).
  • State tied to a lifecycle → scope to that lifecycle (@ViewModelScoped, @ActivityRetainedScoped).
  • User/session state → a custom scope or a singleton you explicitly clear on logout (otherwise it leaks the previous session).
How do you handle navigation between feature modules without coupling them?
Senior #modularization#navigation#decoupling

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

The problem: in a multi-module app, :feature:checkout shouldn’t directly depend on :feature:profile - that creates tight coupling and dependency cycles. But they sometimes need to navigate to each other.

Solutions (least to most decoupled):

1. Route-based navigation (Navigation component). Features expose routes/deep links (strings or type-safe), and navigation goes through a shared NavController. A feature navigates by route without importing the destination feature’s classes.

navController.navigate("profile/$userId")   // no compile dep on :feature:profile

The :app module assembles all feature nav graphs. Features depend on a tiny :core:navigation contract (route constants/keys), not on each other.

2. Navigation abstraction / API modules. Define an interface in a shared module:

// :core:navigation
interface ProfileNavigator { fun openProfile(id: String) }

The :feature:profile module implements it; other features inject ProfileNavigator and call it. Implementation is wired by DI in :app. This keeps features depending on abstractions, not each other.

3. api vs impl module split. A feature exposes a small :feature:profile:api (interfaces, navigation entry points) that others depend on, while :feature:profile:impl stays private. Maximum decoupling for large codebases.

Key principles:

  • Features depend on core/abstractions, never on each other - avoids cycles and keeps build parallelism.
  • The :app module is the composition root - it knows all features and wires the graph/DI.
  • Deep links double as the inter-feature navigation contract.
  • Type-safe routes (Navigation 2.8+) reduce stringly-typed errors.
How does Hilt work? Explain components, scopes, modules, and bindings.
Senior #hilt#dagger#dependency-injection

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Hilt is a DI framework built on Dagger that standardizes DI on Android with predefined components tied to Android lifecycles.

Setup: annotate the Application with @HiltAndroidApp (creates the app-level component), and inject into Android classes with @AndroidEntryPoint.

Components & scopes - Hilt generates a component hierarchy mirroring Android lifecycles; each has a scope annotation:

ComponentScopeLifetime
SingletonComponent@SingletonApplication
ActivityRetainedComponent@ActivityRetainedScopedacross config changes
ViewModelComponent@ViewModelScopeda ViewModel
ActivityComponent@ActivityScopedan Activity
FragmentComponent@FragmentScopeda Fragment

A scoped binding returns the same instance within that component’s lifetime; unscoped returns a new instance each request.

Providing dependencies:

  • Constructor injection - @Inject constructor(...); Hilt knows how to build it.
  • Modules (@Module @InstallIn(SomeComponent::class)) - for types you can’t annotate (interfaces, third-party classes):
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(): Retrofit = Retrofit.Builder()...build()
}
  • @Binds - bind an interface to its implementation efficiently:
@Binds abstract fun bindRepo(impl: UserRepositoryImpl): UserRepository

ViewModels: annotate with @HiltViewModel + @Inject constructor; retrieve with hiltViewModel() (Compose) or by viewModels().

What to remember:

  • Hilt is compile-time and type-safe (Dagger codegen) - errors surface at build time, no reflection, good performance.
  • @Qualifier disambiguates two bindings of the same type (@AuthClient vs @PublicClient OkHttp).
  • Assisted injection (@AssistedInject) for objects needing both DI-provided and runtime params.
  • Match scope to lifecycle - over-scoping (@Singleton everything) causes leaks/stale state; under-scoping recreates expensive objects.
Service Locator vs Dependency Injection - what's the difference?
Senior #dependency-injection#service-locator

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Both manage dependencies, but the direction of control differs.

Dependency Injection - dependencies are pushed in from outside (usually the constructor). The class declares what it needs and receives it; it never asks for anything.

class FeedViewModel(private val repo: FeedRepository)   // dependencies are explicit

Service Locator - the class pulls dependencies from a central registry on demand.

class FeedViewModel {
    private val repo = ServiceLocator.get<FeedRepository>()   // class asks the locator
}

Why DI is generally preferred:

  • Explicit dependencies - the constructor signature documents exactly what the class needs. A service locator hides dependencies inside the body, so you can’t tell what a class requires without reading its implementation.
  • Testability - with DI you just pass a fake in the constructor. With a locator you must configure global state before each test (and reset it after), which is brittle and order-dependent.
  • Compile-time safety - frameworks like Dagger/Hilt verify the graph at build time; a locator typically fails at runtime when a dependency is missing.
  • No hidden global state - the locator is global mutable state, with all the coupling/testing problems that implies.

Where it’s nuanced:

  • Koin is technically closer to a service locator (you call get()/by inject()), though it presents a DI-like DSL - that’s a common interview “gotcha.”
  • Service locators are simpler to set up and can be pragmatic for small apps or to bootstrap before a full DI framework.
Should the network, database, domain, and UI use separate models?
Senior #models#mapping#clean-architecture#layers

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

In a layered architecture, the same concept (“User”) often has separate models per layer, with mappers at the boundaries:

  • DTO (network) - shape of the API response. Has serialization annotations (@SerializedName), nullable fields, server quirks.
  • Entity (database) - Room @Entity; has DB concerns (@PrimaryKey, column info, denormalization).
  • Domain model - clean Kotlin used by use cases/business logic. No framework annotations.
  • UI model - pre-formatted for display (e.g. "3h ago" instead of a timestamp, a resolved color/label).
fun UserDto.toDomain() = User(id = id, name = name ?: "Unknown")
fun User.toUi() = UserUiModel(name = name, initials = name.take(2).uppercase())

Why separate them:

  • Decoupling - a backend field rename only touches the DTO + its mapper, not the whole app. The UI doesn’t break because the API changed.
  • Each layer models its own concerns - nullability/serialization at the edge, clean types in the middle, display-ready in the UI.
  • Testability & clarity - domain logic works on clean models without server cruft.

The pragmatic counterpoint (interviewers reward this balance):

  • For a simple app, 3–4 models + mappers per entity is massive boilerplate for little gain. It’s fine to share a single model across layers when the app is small and the API maps cleanly to the UI.
  • Introduce separate models where the friction is real - e.g. when the API is messy, when one screen aggregates several sources, or when domain logic shouldn’t see serialization details. Don’t apply it dogmatically everywhere.

Where mapping lives: typically in the data layer (DTO/Entity → Domain) and presentation layer (Domain → UI), often as extension functions or dedicated Mapper classes (easy to unit-test).

What is assisted injection, and when do you need it?
Senior #hilt#dagger#assisted-injection#dependency-injection

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Assisted injection is for objects that need both DI-provided dependencies and runtime parameters only known at the call site (an item id, a config object). DI provides some constructor args; the caller “assists” with the rest.

class DetailViewModel @AssistedInject constructor(
    private val repo: ItemRepository,      // provided by DI
    @Assisted private val itemId: String,  // provided at runtime
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(itemId: String): DetailViewModel
    }
}

The @AssistedFactory interface is what you inject; you call factory.create(itemId) with the runtime value.

Why you need it: Dagger/Hilt can only provide what’s in the graph. A pure @Inject constructor can’t have a parameter the graph doesn’t know (itemId). Without assisted injection you’d resort to ugly workarounds (passing the id through a setter after creation, or a manual factory).

Common Android use cases:

  • A ViewModel that needs a runtime argument (though SavedStateHandle often covers nav args - Hilt populates it from the back stack, so prefer SavedStateHandle when the value is a navigation argument).
  • A WorkManager Worker needing injected deps + runtime WorkerParameters - Hilt’s @HiltWorker + @AssistedInject handle exactly this.
  • A presenter/use case parameterized by a runtime id or callback.
@HiltWorker
class SyncWorker @AssistedInject constructor(
    @Assisted ctx: Context,
    @Assisted params: WorkerParameters,
    private val repo: SyncRepository,        // injected
) : CoroutineWorker(ctx, params)
Why modularize an Android app, and how do you structure modules?
Senior #modularization#gradle#scalability

Optional deep dive: This is useful after you are comfortable with the everyday version of the topic. Focus on the main idea first; the implementation details are a senior-level follow-up.

Splitting a single :app module into many Gradle modules pays off as a codebase/team grows.

Benefits:

  • Build speed - Gradle builds modules in parallel and only recompiles changed modules (incremental builds). A one-line change doesn’t rebuild the world.
  • Separation & encapsulation - a module exposes a small api surface and hides internals (internal + implementation deps), enforcing boundaries the compiler checks.
  • Team scalability - teams own modules with fewer merge conflicts.
  • Reusability - share modules across apps (e.g. a design-system module).
  • Dynamic delivery - feature modules can be downloaded on demand.

Common structures:

  • By layer (:data, :domain, :ui) - simple, but every feature touches every module → poor parallelism and ownership at scale.
  • By feature (:feature:feed, :feature:profile) - preferred for larger apps; each feature is independent and can itself be layered internally.
  • Hybrid (recommended) - feature modules + shared :core modules (:core:network, :core:database, :core:designsystem, :core:common). This is the Now in Android sample’s approach.
:app                      (wires features together, DI setup)
:feature:feed   :feature:profile   :feature:settings
:core:data   :core:domain   :core:network   :core:database   :core:designsystem

Useful design rules:

  • api vs implementation - use implementation to keep a dependency off the consuming module’s compile classpath (faster builds, real encapsulation); use api only when a type leaks into your public API.
  • Avoid cyclic dependencies - features shouldn’t depend on each other directly; route through a navigation/abstraction module or :core.
  • Feature modules depend on core, not vice versa (dependency rule).
  • Convention plugins (build-logic) to share Gradle config and avoid copy-paste.

Trade-offs: more boilerplate (Gradle files), a steeper setup, and cross-module navigation/DI wiring complexity. Worth it for medium/large apps; overkill for a tiny one.