DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Android Error Handling & Type-Safe Navigation — Architecture Patterns

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
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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 -> {}
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()))
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)