Android Error Handling & Navigation Arguments — Clean Architecture Patterns
Error handling and type-safe navigation are critical for building robust Android apps. This article covers proven patterns using Result sealed classes, AppException hierarchies, and modern Compose navigation with type-safe arguments.
Part 1: Error Handling Architecture
Result Sealed Class Pattern
The Result sealed class pattern provides type-safe error handling across the entire data layer:
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val exception: AppException) : Result<Nothing>()
data class Loading(val progress: Int = 0) : Result<Nothing>()
fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(data))
is Error -> Error(exception)
is Loading -> Loading(progress)
}
fun getOrNull(): T? = (this as? Success)?.data
}
AppException Hierarchy
Define a comprehensive exception hierarchy for all application errors:
sealed class AppException(message: String) : Exception(message) {
sealed class Network(message: String) : AppException(message) {
data class Timeout(val url: String) : Network("Timeout loading $url")
data class Connection(val cause: Throwable) : Network("No internet")
data class Unknown(val cause: Throwable) : Network(cause.message ?: "Unknown network error")
}
sealed class Server(message: String) : AppException(message) {
data class Http(val code: Int, val errorBody: String) : Server("HTTP $code: $errorBody")
data class Parsing(val cause: Throwable) : Server("Failed to parse server response")
data class NotFound : Server("Resource not found")
}
sealed class Local(message: String) : AppException(message) {
data class Storage(val cause: Throwable) : Local("Storage error: ${cause.message}")
data class Validation(val field: String, val reason: String) : Local("$field: $reason")
}
fun getUserMessage(): String = when (this) {
is Network.Connection -> "Check your internet connection"
is Network.Timeout -> "Request timed out. Please try again"
is Server.Http -> "Server error (${code}). Please try again later"
is Server.NotFound -> "Resource not found"
is Local.Validation -> "Invalid input: $reason"
is Local.Storage -> "Storage error. Try freeing up space"
else -> "Something went wrong. Please try again"
}
}
Repository Error Conversion
Convert framework exceptions to your AppException hierarchy:
class UserRepository(private val api: UserApi, private val db: UserDao) {
suspend fun getUser(id: String): Result<User> = try {
val user = withTimeout(10.seconds) {
api.fetchUser(id)
}
db.saveUser(user)
Result.Success(user)
} catch (e: TimeoutCancellationException) {
Result.Error(AppException.Network.Timeout("users/$id"))
} catch (e: IOException) {
Result.Error(AppException.Network.Connection(e))
} catch (e: HttpException) {
Result.Error(AppException.Server.Http(e.code(), e.message()))
} catch (e: JsonSyntaxException) {
Result.Error(AppException.Server.Parsing(e))
} catch (e: Exception) {
Result.Error(AppException.Network.Unknown(e))
}
}
ViewModel UiState Pattern
Define a UiState sealed class to represent all possible screen states:
sealed class UserDetailUiState {
object Loading : UserDetailUiState()
data class Success(val user: User, val isEditing: Boolean = false) : UserDetailUiState()
data class Error(val exception: AppException, val canRetry: Boolean = true) : UserDetailUiState()
object Deleted : UserDetailUiState()
}
class UserDetailViewModel(
private val id: String,
private val repository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UserDetailUiState>(UserDetailUiState.Loading)
val uiState: StateFlow<UserDetailUiState> = _uiState.asStateFlow()
init {
loadUser()
}
fun loadUser() {
viewModelScope.launch {
_uiState.value = UserDetailUiState.Loading
when (val result = repository.getUser(id)) {
is Result.Success -> _uiState.value = UserDetailUiState.Success(result.data)
is Result.Error -> _uiState.value = UserDetailUiState.Error(result.exception)
is Result.Loading -> {} // Handled by state machine
}
}
}
fun deleteUser() {
viewModelScope.launch {
when (val result = repository.deleteUser(id)) {
is Result.Success -> _uiState.value = UserDetailUiState.Deleted
is Result.Error -> _uiState.value = UserDetailUiState.Error(result.exception)
is Result.Loading -> {}
}
}
}
}
Error Screen with Retry
Create a reusable error composable:
@Composable
fun ErrorScreen(
exception: AppException,
onRetry: () -> Unit = {},
canRetry: Boolean = true,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.ErrorOutline,
contentDescription = "Error",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Error",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = exception.getUserMessage(),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (canRetry) {
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}
}
@Composable
fun UserDetailScreen(
id: String,
viewModel: UserDetailViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
is UserDetailUiState.Loading -> LoadingScreen()
is UserDetailUiState.Success -> {
UserDetailContent(user = state.user)
}
is UserDetailUiState.Error -> {
ErrorScreen(
exception = state.exception,
onRetry = { viewModel.loadUser() },
canRetry = state.canRetry
)
}
is UserDetailUiState.Deleted -> {
// Navigate back or show success
}
}
}
Flow Error Handling with .catch
Use Flow's .catch operator for stream-based error handling:
class NotificationViewModel(private val repository: NotificationRepository) : ViewModel() {
val notifications: StateFlow<List<Notification>> = repository.observeNotifications()
.catch { e ->
Log.e("NotificationVM", "Error observing notifications", e)
emit(emptyList())
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
// Or with error state:
class ChatViewModel(private val repository: ChatRepository) : ViewModel() {
sealed class ChatUiState {
data class Messages(val items: List<ChatMessage>) : ChatUiState()
data class Error(val exception: AppException) : ChatUiState()
}
val chat: StateFlow<ChatUiState> = repository.observeMessages()
.catch { e ->
emit(ChatUiState.Error(AppException.Network.Unknown(e)))
}
.stateIn(viewModelScope, SharingStarted.Lazily, ChatUiState.Messages(emptyList()))
}
Part 2: Type-Safe Navigation Arguments
Navigation Type System
Define Routes with type-safe arguments:
sealed class Routes(val route: String) {
object Home : Routes("home")
data class UserDetail(val userId: String) : Routes("user/{userId}") {
fun createRoute() = "user/$userId"
}
data class EditUser(
val userId: String,
val userName: String,
val isAdmin: Boolean
) : Routes("user/{userId}/edit?name={name}&admin={admin}") {
fun createRoute() = "user/$userId/edit?name=$userName&admin=$isAdmin"
}
data class Search(val query: String, val limit: Int = 20) : Routes("search?q={q}&limit={limit}") {
fun createRoute() = "search?q=$query&limit=$limit"
}
object Settings : Routes("settings")
}
Navigation Argument Types
Handle different argument types safely:
@Composable
fun AppNavGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = Routes.Home.route) {
composable(Routes.Home.route) {
HomeScreen(
onNavigateToUser = { userId ->
navController.navigate(Routes.UserDetail(userId).createRoute())
}
)
}
// String argument
composable(
route = Routes.UserDetail("{userId}").route,
arguments = listOf(
navArgument("userId") { type = NavType.StringType }
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: return@composable
UserDetailScreen(userId = userId)
}
// Multiple types: String, Int, Boolean, with optional params
composable(
route = Routes.EditUser("{userId}", "{name}", "{admin}").route,
arguments = listOf(
navArgument("userId") { type = NavType.StringType },
navArgument("name") {
type = NavType.StringType
nullable = true
defaultValue = "User"
},
navArgument("admin") {
type = NavType.BoolType
defaultValue = false
}
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") ?: ""
val name = backStackEntry.arguments?.getString("name") ?: "User"
val isAdmin = backStackEntry.arguments?.getBoolean("admin") ?: false
EditUserScreen(userId = userId, userName = name, isAdmin = isAdmin)
}
// Query parameters
composable(
route = Routes.Search("{q}", "{limit}").route,
arguments = listOf(
navArgument("q") { type = NavType.StringType },
navArgument("limit") {
type = NavType.IntType
defaultValue = 20
}
)
) { backStackEntry ->
val query = backStackEntry.arguments?.getString("q") ?: ""
val limit = backStackEntry.arguments?.getInt("limit") ?: 20
SearchScreen(query = query, limit = limit)
}
}
}
SavedStateHandle for Screen Results
Return results from child screens using SavedStateHandle:
@Composable
fun UserListScreen(navController: NavHostController) {
val savedStateHandle = rememberSavedStateHandle()
// Observe result from EditUserScreen
LaunchedEffect(Unit) {
savedStateHandle.getStateFlow<User?>("selected_user", null)
.collect { user ->
if (user != null) {
// User was edited, refresh list
Log.d("UserList", "User updated: ${user.name}")
}
}
}
LazyColumn {
items(users) { user ->
UserItem(
user = user,
onEdit = {
navController.navigate(
Routes.EditUser(user.id, user.name, user.isAdmin).createRoute()
)
}
)
}
}
}
@Composable
fun EditUserScreen(
userId: String,
userName: String,
isAdmin: Boolean,
navController: NavHostController,
viewModel: EditUserViewModel = hiltViewModel()
) {
val savedStateHandle = rememberSavedStateHandle()
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is EditUserUiState.Loading -> LoadingScreen()
is EditUserUiState.Success -> {
EditUserContent(
user = (uiState as EditUserUiState.Success).user,
onSave = { updatedUser ->
viewModel.saveUser(updatedUser)
savedStateHandle["selected_user"] = updatedUser
navController.popBackStack()
}
)
}
is EditUserUiState.Error -> {
ErrorScreen(exception = (uiState as EditUserUiState.Error).exception)
}
}
}
Optional Arguments Pattern
Handle truly optional arguments safely:
sealed class DetailRoutes(val route: String) {
object UserDetail : DetailRoutes("user/{id}?referrer={referrer}")
}
composable(
route = DetailRoutes.UserDetail.route,
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("referrer") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id") ?: ""
val referrer = backStackEntry.arguments?.getString("referrer")
UserDetailScreen(id = id, referrer = referrer)
}
Integration: Error Handling + Navigation
Combine error handling with navigation for complete flow control:
class CheckoutViewModel(
private val paymentRepository: PaymentRepository,
private val analyticsRepository: AnalyticsRepository
) : ViewModel() {
sealed class CheckoutUiState {
object Idle : CheckoutUiState()
object Processing : CheckoutUiState()
data class Success(val orderId: String) : CheckoutUiState()
data class Error(val exception: AppException, val canRetry: Boolean) : CheckoutUiState()
}
private val _uiState = MutableStateFlow<CheckoutUiState>(CheckoutUiState.Idle)
val uiState: StateFlow<CheckoutUiState> = _uiState.asStateFlow()
fun processPayment(amount: Double, cardToken: String) {
viewModelScope.launch {
_uiState.value = CheckoutUiState.Processing
when (val result = paymentRepository.charge(amount, cardToken)) {
is Result.Success -> {
analyticsRepository.trackPurchase(result.data.orderId, amount)
_uiState.value = CheckoutUiState.Success(result.data.orderId)
}
is Result.Error -> {
val canRetry = result.exception !is AppException.Server.NotFound
_uiState.value = CheckoutUiState.Error(result.exception, canRetry)
}
is Result.Loading -> {}
}
}
}
}
@Composable
fun CheckoutFlow(navController: NavHostController) {
val viewModel: CheckoutViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
when (val state = uiState) {
is CheckoutViewModel.CheckoutUiState.Idle -> {
CheckoutForm(
onSubmit = { amount, token ->
viewModel.processPayment(amount, token)
}
)
}
is CheckoutViewModel.CheckoutUiState.Processing -> {
LoadingScreen("Processing payment...")
}
is CheckoutViewModel.CheckoutUiState.Success -> {
LaunchedEffect(state.orderId) {
navController.navigate(
Routes.OrderConfirmation(state.orderId).createRoute()
) {
popUpTo(Routes.Home.route) { inclusive = false }
}
}
}
is CheckoutViewModel.CheckoutUiState.Error -> {
ErrorScreen(
exception = state.exception,
onRetry = { navController.popBackStack() },
canRetry = state.canRetry
)
}
}
}
Key Takeaways
- Result pattern provides type-safe error handling across data layer
- AppException hierarchy maps framework exceptions to business-relevant errors
- UiState sealed classes represent all screen states including errors
- Type-safe navigation prevents runtime argument mismatches
- SavedStateHandle enables proper screen-to-screen result passing
- .catch operator handles Flow errors elegantly
These patterns combine to create Android apps that are maintainable, testable, and handle all error cases explicitly.
Want more Android patterns?
Check out 8 Android App Templates with complete implementations of MVVM, error handling, navigation, and offline-first sync:
→ https://myougatheax.gumroad.com
Join hundreds of developers building robust Android apps with clean architecture.
Top comments (0)