Architecture and dependency injection cheat sheet
The 15 Architecture and dependency injection interview answers in 30 seconds each, plus quick facts. Printable, free.
One page per topic: print it or save it as PDF from your browser's print menu. Practise these questions
Rules of thumb
- UI shows state and sends events; repositories own data.
- One immutable UiState per screen, state down and events up.
- The database is the single source of truth; the network writes into it.
- Constructor injection; @Binds for interfaces; scope only shared state.
- Feature modules never depend on each other.
UI, domain and data layers
The UI layer shows state and sends events: composables or Views plus state holders such as ViewModels. The data layer owns data through repositories, each exposing data for one area and hiding the sources (network, database, files) behind it. An optional domain layer of use cases sits in between for business logic that several ViewModels share. Dependencies point inward: UI depends on data, never the reverse.
Unidirectional data flow and UiState
In unidirectional data flow, state flows down from a state holder to the UI and events flow up from the UI to the state holder, which updates the state. The ViewModel exposes one immutable UiState (often a data class or sealed interface) through a StateFlow, and the UI only renders it. There's one owner and one direction, so the screen is predictable and easy to test.
MVVM vs MVI
Both keep logic out of the UI. In MVVM, the ViewModel exposes state and functions the UI calls. In MVI, the UI sends intents (events) as objects to a single entry point, a reducer turns the current state and the intent into a new state, and side effects are handled separately. MVI makes every change explicit and replayable; MVVM with one UiState is lighter and is what most Android teams use.
data class CounterState(val count: Int = 0)
sealed interface CounterIntent {
data object Increment : CounterIntent
data class Set(val value: Int) : CounterIntent
}
fun reduce(state: CounterState, intent: CounterIntent): CounterState = when (intent) {
CounterIntent.Increment -> state.copy(count = state.count + 1)
is CounterIntent.Set -> state.copy(count = intent.value)
}Repositories and a single source of truth
A repository is the only entry point to one kind of data, like articles or the user profile. It hides where data comes from, merges network and local sources, and exposes it as Kotlin types and Flows. Single source of truth means the UI always reads from one place, usually the database: the network writes into it, and the UI observes it, so every screen agrees.
@Entity data class ArticleEntity(@PrimaryKey val id: String, val title: String)
@Dao interface ArticleDao {
@Query("SELECT * FROM ArticleEntity") fun observe(): Flow<List<ArticleEntity>>
@Upsert suspend fun upsert(items: List<ArticleEntity>)
}
class ArticleRepository(private val dao: ArticleDao, private val api: Api) {
val articles: Flow<List<Article>> = dao.observe().map { list -> list.map { Article(it.id, it.title) } }
suspend fun refresh() {
dao.upsert(api.feed().map { ArticleEntity(it.id, it.title) })
}
}Use cases and the domain layer
A use case (interactor) is a small class with one job, such as GetFeedWithAuthorsUseCase, often with operator fun invoke. It holds business logic that combines repositories or would otherwise be copied between ViewModels. It's worth adding when logic is reused or complex; wrapping every single repository call in a use case only adds files.
class GetGreetingUseCase(private val users: UserRepository) {
suspend operator fun invoke(id: String): String {
val user = users.user(id)
return "Hello, ${user.name}"
}
}Dependency injection
Dependency injection means a class receives what it needs, usually through its constructor, instead of creating it. That makes dependencies visible, lets tests pass fakes, and lets one place decide lifetimes such as a single HTTP client for the app. On Android, Hilt is the standard library; manual DI with a container class is fine for small apps.
Hilt basics
Annotate the Application with @HiltAndroidApp, Android entry points with @AndroidEntryPoint, and ViewModels with @HiltViewModel. @Inject constructor tells Hilt how to build your own classes. For types you don't own, like Retrofit, or for interfaces, write a @Module installed in a component with @Provides functions or @Binds for interface-to-implementation.
Hilt components and scopes
Each Hilt component has a lifetime: SingletonComponent (app), ActivityRetainedComponent (survives rotation), ViewModelComponent, ActivityComponent, FragmentComponent and more. A scope annotation like @Singleton or @ViewModelScoped means one instance per component instance. Unscoped bindings create a new instance per injection, which is the cheap default; scope only when sharing is required.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun client(): OkHttpClient = OkHttpClient.Builder().build()
}Runtime arguments with Hilt
For ViewModels, read navigation arguments from SavedStateHandle, which Hilt injects, so the value also survives process death. For other classes that need runtime values plus injected ones, use assisted injection: @AssistedInject constructor(@Assisted id: String, repo: Repo) with an @AssistedFactory interface. Hilt ViewModels also support assisted factories through hiltViewModel(creationCallback = ...).
class Uploader @AssistedInject constructor(
@Assisted private val fileName: String,
private val repo: UserRepository,
) {
fun describe() = "Uploading $fileName"
}
@AssistedFactory
interface UploaderFactory {
fun create(fileName: String): Uploader
}Modularisation
Modules give faster incremental builds (only changed modules recompile), enforced boundaries (internal and explicit dependencies), parallel work across teams and reusable pieces. A common structure is :app, :feature:* modules for screens, :core:* modules for data, design system and utilities, and optional API/implementation splits so features don't depend on each other's internals.
Modelling errors
Catch exceptions at the boundary where you can act on them, usually in the repository or ViewModel, and turn them into a typed result the UI can render: a sealed Result/Outcome type or an error field in UiState. Map low-level exceptions (IOException, HttpException) to app-level errors like Offline or NotFound, and keep messages and retry logic out of the data layer's types.
sealed interface LoadError {
data object Offline : LoadError
data object NotFound : LoadError
data class Unexpected(val cause: Throwable) : LoadError
}
fun Throwable.toLoadError(): LoadError = when (this) {
is java.io.IOException -> LoadError.Offline
else -> LoadError.Unexpected(this)
}SOLID on Android
Single responsibility: a ViewModel shapes one screen's state, a repository owns one kind of data. Open/closed: add a new payment method as a new implementation, not by editing a big when. Liskov: fakes must behave like the real thing. Interface segregation: small interfaces like ArticleReader instead of one giant repository. Dependency inversion: ViewModels depend on interfaces that DI binds.
Design patterns you use every day
Observer: Flows and StateFlow. Builder: NotificationCompat.Builder, OkHttpClient.Builder. Factory: ViewModel factories and Hilt providers. Adapter: RecyclerView adapters and Retrofit converters. Facade: a repository hiding several sources. Strategy: interchangeable implementations behind an interface. Decorator: a class that wraps another implementation of the same interface to add behaviour. Chain of responsibility: OkHttp interceptors, each passing the call on.
Navigation between feature modules
Each feature exposes a small public API: its route types and a NavGraphBuilder.featureGraph() extension, often in a separate api module. The app module, which depends on all features, assembles the NavHost and passes navigation callbacks like onOpenProfile: (String) -> Unit into each feature. Features only see routes and lambdas, never each other's screens.
Hilt vs Koin vs manual DI
Hilt is built on Dagger and generates the dependency graph at compile time, so missing bindings fail the build and runtime cost is low; it has Android-specific components and is Google's recommendation. Koin is a Kotlin DSL that resolves dependencies at runtime, simple to set up and Multiplatform-friendly, but errors appear when the app runs unless you add checks. Manual DI with a container class is fine for small apps and libraries.
Quick facts
- @HiltAndroidApp
- @HiltAndroidApp on the Application class triggers Hilt's code generation and creates the app-level component.
- hiltViewModel()
- hiltViewModel() returns a Hilt ViewModel scoped to the current navigation entry or Activity.
- Convention plugins
- Convention plugins in an included build-logic build share Gradle configuration across modules.
- Now in Android
- Google's Now in Android sample app shows the recommended layers and modularisation.
- ActivityRetainedComponent
- ActivityRetainedComponent lives across configuration changes, like a ViewModel.