Android Architecture series · Part 6 of 9
DI, Async and Errors: The Plumbing Every Architecture Needs
Whatever architecture you pick, you need the same plumbing: a way to hand objects to classes, run slow work off the main thread, respect the lifecycle, and fail gracefully.
- DI: pass dependencies in; Hilt is the default, Koin is simpler.
- Async: coroutines and Flow for new code; Rx is fine; AsyncTask is deprecated.
- Lifecycle: tie work to the screen so nothing leaks.
- Errors: try/catch inside the coroutine, onError in Rx, one shared error type.
1. Dependency injection (DI)
A class almost always needs other objects: a presenter needs a repository, a repository needs an API. Dependency injection means the class does not build those objects itself. They are handed in from outside, usually through the constructor.
// Hard to test and hard to change: it builds its own dependencies
class ProfilePresenter {
private val repo = DefaultProfileRepository(RetrofitClient.create())
}
// DI: asks for what it needs, depends on the interface
class ProfilePresenter(private val repo: ProfileRepository)
Restaurant again. A chef should not drive to the farm for tomatoes. Tomatoes arrive at the kitchen door. Which farm they come from is somebody else’s job, and can change without the chef noticing.
You can do DI by hand (a function that builds and passes the objects), but with many classes a library saves effort:
| Library | Style | Notes |
|---|---|---|
| Hilt | Compile-time, annotation-based | Google’s recommended choice. Built on Dagger, with Android scopes (@HiltAndroidApp, @HiltViewModel). Errors show at build time. |
| Dagger 2 | Compile-time | The engine under Hilt. Powerful, more setup. |
| Koin | Runtime, Kotlin DSL | Easy to read and start with. Missing bindings fail at runtime. (Technically a service locator.) |
| Kodein, kotlin-inject | DSL / compile-time | Kotlin-first alternatives, also work outside Android. |
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds abstract fun profileRepo(impl: DefaultProfileRepository): ProfileRepository
}
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides fun dispatchers(): DispatcherProvider = object : DispatcherProvider {
override val io = Dispatchers.IO
override val main = Dispatchers.Main
}
}
In tests you swap the module for a fake one. This is how the Dependency Inversion principle from Part 2 becomes practical.
2. Asynchronous code
Android draws the screen on the main thread. If you block it with a network call for more than a few seconds the system shows “App Not Responding”. So slow work runs in the background and the result comes back to the main thread. The tools changed over the years:
| Tool | Status | One-line summary |
|---|---|---|
AsyncTask | Deprecated (API 30). Do not use for new code. | Easy for beginners, but leaked Activities and broke on rotation. |
| Thread, Handler, Executor | Works, low level | You manage everything yourself. |
| RxJava | Mature, still common in big codebases | Streams with operators; subscribeOn/observeOn choose threads. |
| Kotlin coroutines + Flow | Recommended by Google | Write async code that reads like normal code; cancellation follows lifecycle. |
// Coroutines
viewModelScope.launch {
val profile = withContext(dispatchers.io) { repo.getProfile(id) }
_state.value = ProfileUiState.Content(profile)
}
// RxJava (same idea)
disposables += repo.getProfile(id) // Single<Profile>
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
.subscribe({ _state.value = Content(it) }, { _state.value = Error("No connection") })
Both style examples use the Scheduler pattern from Part 2, so tests can run them instantly. Rule of thumb: new code uses coroutines and Flow; keep Rx where it already works and migrate slowly (there are adapters such as kotlinx-coroutines-rx3 to bridge them).
3. Lifecycle handling
An Activity or Fragment is created, shown, hidden and destroyed by the system, sometimes at moments you do not control (rotation, low memory, the user pressing Home). Work that outlives its screen is the root of leaks and crashes.
- Presenter (MVP): attach in
onCreate/onStart,detach()inonDestroy/onStop, cancel jobs. - ViewModel (MVVM): use
viewModelScope; it cancels itself when the ViewModel is cleared. - View collecting a Flow: use
repeatOnLifecycle(STARTED)orcollectAsStateWithLifecycle()so you do not update a hidden screen. - Rx: keep a
CompositeDisposableand callclear()when the screen goes away.
4. Error handling
Networks fail, disks fill up, servers return surprises. Decide in the data layer what the error means, and let the logic layer turn it into state the user can understand.
Coroutines: try/catch inside the coroutine
viewModelScope.launch {
try {
val profile = repo.getProfile(id)
_state.value = Content(profile)
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: IOException) {
_state.value = Error("No connection")
} catch (e: HttpException) {
_state.value = Error("Server problem (${e.code()})")
}
}
- Put the
try/catchinsidelaunch. Wrapping thelaunchcall itself does not catch the exception thrown inside it. - Always rethrow
CancellationException; it is how cancellation works. - For
async, catch where you callawait(). UsesupervisorScopeso one failing child does not cancel its siblings. CoroutineExceptionHandleris a last-resort logger forlaunch, not a place for normal error handling.- In Flow, use the
catch { }operator for upstream errors.
Rx: errors are part of the stream
repo.getProfile(id)
.subscribeOn(schedulers.io())
.observeOn(schedulers.ui())
.retry(2) // try again on failure
.onErrorReturn { cachedProfile } // or fall back to a value
.subscribe(
{ _state.value = Content(it) },
{ _state.value = Error("No connection") } // ALWAYS provide onError
)
If you forget the onError callback, Rx throws OnErrorNotImplementedException and the app can crash. Set a global fallback with RxJavaPlugins.setErrorHandler for exceptions that arrive after a stream is disposed.
One shared error model
Do not let HttpException or SQLiteException leak to the screen. Map them once into your own small type:
sealed interface AppError {
data object NoConnection : AppError
data class Server(val code: Int) : AppError
data object Unknown : AppError
}
Now every architecture (presenter, ViewModel, interactor, reducer) handles the same few cases and shows the same friendly messages. That is DRY in action.