Architecture & Patterns
MVVM/MVI, Clean Architecture, repositories, dependency injection, modularization, design patterns, and structuring an app that scales and is testable.
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/implsplits, 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?
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 { },Modifierchains, 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.
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.
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;collectis observing.- Compose state - reading a
Statesubscribes 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/repeatOnLifecyclesolve 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?
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
RetrofitClientandRoomDatabase- changing either breaks the ViewModel. - Loosely coupled: the ViewModel depends on a
Repositoryinterface, 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
Utilsclass with networking, date formatting, and bitmap helpers thrown together. - High cohesion: a
DateFormatterthat only formats dates; aFeedRepositorythat 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?
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?
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
Describe Google's recommended app architecture.
Google’s official guidance defines two-to-three layers with UDF between them and a few firm principles.
Layers:
- UI layer -
ViewModel+ UI (Compose/Views). The ViewModel holdsUiStateand exposes it as an observable stream; the UI renders it and sends events up. - Domain layer (optional) - use cases for reusable/complex business logic, sitting between UI and data.
- Data layer - repositories (the public API of the layer) over data sources (network, DB, DataStore). Repositories own the single source of truth and the data policy.
UI (ViewModel + Compose) ──▶ Domain (UseCases) ──▶ Data (Repository → sources)
▲────────────────── state flows back ──────────────────┘
The core principles Google emphasizes:
- Separation of concerns - UI is thin; logic lives in ViewModel/domain/data, not Activities/Fragments.
- Drive UI from data models - ideally immutable, observable state; UDF (state down, events up).
- Single source of truth - each data type has one owner (usually the repository/DB) that others read; mutations go through it.
- Unidirectional data flow - events flow up, state flows down.
Practical specifics:
- ViewModel exposes
StateFlow<UiState>, often viastateIn(WhileSubscribed(5000)). - Repository exposes
Flows; DB (Room) is the source of truth for offline-first. - Dependencies injected (Hilt); each layer testable in isolation.
- Scale up with modularization (feature + core modules) as in Now in Android.
Pragmatism: the domain layer is optional - add it when logic is shared or complex; ViewModel → Repository is fine otherwise. Don’t over-engineer simple screens.
Explain Clean Architecture on Android. What are the layers and the dependency rule?
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.
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.
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
PaymentProcessorinterface). - 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.ViewHoldercreation inonCreateViewHolder,LayoutInflater.Factory, Retrofit/OkHttp builders internally, and DI@Providesmethods 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.
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
RecyclerViewview type or a newPaymentMethodby adding a class, not editing a giantwheneverywhere. 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
FakeRepositorymust honor theRepositorycontract 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 intoOnClick,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
UserRepositoryinterface, notRetrofitUserRepository. 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.
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/GridLayoutManagerare 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 interfacepassed 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?
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 safety → Hilt (Google’s recommended default on Android).
- Smaller apps, rapid prototyping, KMP, or teams prioritizing simplicity and build speed → Koin.
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?
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):
- UI observes a Room
Flow→ shows cached data immediately (even offline). - Repository decides whether to refresh (stale? forced?).
- If refreshing, fetch from network → write into Room.
- 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 +
RemoteMediatorimplements 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?
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:
- Holding a
Context/Viewleaks it. Anobjectlives for the whole process. If it stores an Activity context, that Activity can never be GC’d. StoreapplicationContextonly, or don’t hold context at all.object Bad { lateinit var ctx: Context } // if assigned an Activity → permanent leak - 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.
- Testability - a hard-coded
objectdependency can’t be swapped for a fake. This is the big one: prefer DI with@Singletonscope over a manualobject, so the single instance is provided and replaceable in tests. - Initialization order / parameters - an
objectcan’t take constructor parameters; if it needs config, you end up with aninit(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)
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 classwhose fields include a sealedcontent: ContentState.
Principles regardless of shape:
- Immutable - expose a single
StateFlow<UiState>; update withcopy()/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
showEmptyStatefrom 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?
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-backedPagingSourceserves the UI from the DB.Pager- config (page size, prefetch) that produces aFlow<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?
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?
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
AppErrorforces the UI to handle each case (retry, re-login, generic message). - For Flow, surface errors via a result-emitting flow or the
catchoperator mapping to an error state - never an unhandled throw incollect. - Never catch
CancellationExceptionin a blanket catch - rethrow it.
How would you architect feature flags / remote config?
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
FeatureFlagsto 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
ifchecks everywhere.
MVC vs MVP vs MVVM - how did Android presentation patterns evolve?
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
Viewinterface 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
Viewinterface 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?
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.
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:
ArticleRepositoryinterface (domain) + impl (data). ExposesobserveSaved(): 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
SavedViewModelexposesStateFlow<SavedUiState>(immutable state: loading/items/error) viastateIn(WhileSubscribed(5000)); handles events (onToggleSave); emits one-off events (snackbar) on aChannel.SavedScreen(Compose) collects state withcollectAsStateWithLifecycle(), renders, sends events up (UDF).
4. Wiring
- Hilt provides the API, DAO, repository (
@Bindsinterface→impl), scoped appropriately (@Singletonfor DB/network,@HiltViewModelfor 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?
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?
Caching is layered; pick per data type and freshness need.
Cache tiers (fastest → most durable):
- In-memory - a
MutableStateFlow/LruCachein 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/ETagfor 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 Modifiedto 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?
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?
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;
SavedStateHandleextends that to process death for the few keys that matter. - It replaces manual
onSaveInstanceStateplumbing 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?
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-onlyStateFlow. - 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()+ atomicupdate {}. - 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?
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 -
runTestcontrols the virtual clock;advanceUntilIdle()works; no flakiness. - Flexibility - swap dispatchers per environment without touching logic.
- Honors structured concurrency -
viewModelScopealready usesMain; 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?
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/actualmechanism.
// commonMain
expect class PlatformContext
expect fun httpClientEngine(): HttpClientEngine
// androidMain / iosMain provide the `actual` implementations
Key architecture decisions interviewers probe:
expect/actualfor 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/Flowneed 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?
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:
| Scope | One instance per | Use for |
|---|---|---|
@Singleton | application | DB, Retrofit, OkHttp, app-wide repos |
@ActivityRetainedScoped | survives config change | shared across an Activity + its ViewModels |
@ViewModelScoped | a ViewModel | use cases/helpers tied to one screen’s VM |
@ActivityScoped | an Activity | Activity-bound helpers |
@FragmentScoped | a Fragment | fragment-bound helpers |
| (unscoped) | every injection | stateless, 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 does Hilt work? Explain components, scopes, modules, and bindings.
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:
| Component | Scope | Lifetime |
|---|---|---|
SingletonComponent | @Singleton | Application |
ActivityRetainedComponent | @ActivityRetainedScoped | across config changes |
ViewModelComponent | @ViewModelScoped | a ViewModel |
ActivityComponent | @ActivityScoped | an Activity |
FragmentComponent | @FragmentScoped | a 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.
@Qualifierdisambiguates two bindings of the same type (@AuthClientvs@PublicClientOkHttp).- Assisted injection (
@AssistedInject) for objects needing both DI-provided and runtime params. - Match scope to lifecycle - over-scoping (
@Singletoneverything) causes leaks/stale state; under-scoping recreates expensive objects.
Service Locator vs Dependency Injection - what's the difference?
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?
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?
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
SavedStateHandleoften covers nav args - Hilt populates it from the back stack, so preferSavedStateHandlewhen the value is a navigation argument). - A WorkManager
Workerneeding injected deps + runtimeWorkerParameters- Hilt’s@HiltWorker+@AssistedInjecthandle 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?
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
apisurface and hides internals (internal+implementationdeps), 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
:coremodules (: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:
apivsimplementation- useimplementationto keep a dependency off the consuming module’s compile classpath (faster builds, real encapsulation); useapionly 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.