Never use raw String for errors. Define a structured domain error hierarchy:
// core/util/AppError.kt — commonMain
sealed class AppError : Throwable() {
// Network errors
sealed class Network : AppError() {
data object NoConnection : Network()
data object Timeout : Network()
data class ServerError(val code: Int, val body: String?) : Network()
data class Unauthorized(val message: String = "Session expired") : Network()
data object Unknown : Network()
}
// Local persistence errors
sealed class Database : AppError() {
data class ReadFailure(override val cause: Throwable?) : Database()
data class WriteFailure(override val cause: Throwable?) : Database()
data object NotFound : Database()
}
// Business/domain validation errors
sealed class Validation : AppError() {
data class InvalidInput(val field: String, val reason: String) : Validation()
data object RequiredFieldMissing : Validation()
}
// Unknown/unexpected
data class Unexpected(override val cause: Throwable?) : AppError()
}
// core/util/Resource.kt — commonMain
sealed class Resource<out T> {
data class Success<out T>(val data: T) : Resource<T>()
data class Error(val error: AppError) : Resource<Nothing>()
data object Loading : Resource<Nothing>()
}
// Extension functions for ergonomic handling
inline fun <T> Resource<T>.onSuccess(action: (T) -> Unit): Resource<T> {
if (this is Resource.Success) action(data)
return this
}
inline fun <T> Resource<T>.onError(action: (AppError) -> Unit): Resource<T> {
if (this is Resource.Error) action(error)
return this
}
inline fun <T> Resource<T>.onLoading(action: () -> Unit): Resource<T> {
if (this is Resource.Loading) action()
return this
}
inline fun <T, R> Resource<T>.map(transform: (T) -> R): Resource<R> = when (this) {
is Resource.Success -> Resource.Success(transform(data))
is Resource.Error -> this
is Resource.Loading -> this
}
Map HTTP and network exceptions to domain errors at the data layer boundary — never let ClientRequestException or IOException escape into domain/presentation:
// core/network/NetworkErrorMapper.kt — commonMain
import io.ktor.client.plugins.*
import io.ktor.http.*
suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
return try {
Resource.Success(call())
} catch (e: ClientRequestException) {
val error = when (e.response.status) {
HttpStatusCode.Unauthorized -> AppError.Network.Unauthorized()
HttpStatusCode.NotFound -> AppError.Database.NotFound
else -> AppError.Network.ServerError(
code = e.response.status.value,
body = e.response.toString()
)
}
Resource.Error(error)
} catch (e: ServerResponseException) {
Resource.Error(AppError.Network.ServerError(e.response.status.value, null))
} catch (e: HttpRequestTimeoutException) {
Resource.Error(AppError.Network.Timeout)
} catch (e: Exception) {
if (e.message?.contains("Unable to resolve host") == true ||
e.message?.contains("Network is unreachable") == true) {
Resource.Error(AppError.Network.NoConnection)
} else {
Resource.Error(AppError.Unexpected(e))
}
}
}
Usage in repository:
class UserRepositoryImpl(private val api: UserApiService) : UserRepository {
override suspend fun getUser(id: String): Resource<User> =
safeApiCall { api.getUser(id) }.map { it.toDomain() }
}
// Wrap all database calls similarly
suspend fun <T> safeDbCall(call: suspend () -> T): Resource<T> {
return try {
Resource.Success(call())
} catch (e: Exception) {
Resource.Error(AppError.Database.ReadFailure(e))
}
}
Map domain errors to user-facing messages at the presentation layer:
// Presentation layer — never expose AppError directly to the UI
data class HomeUiState(
val isLoading: Boolean = false,
val items: List<Item> = emptyList(),
val errorMessage: String? = null // human-readable, never AppError
)
class HomeViewModel(private val getItemsUseCase: GetItemsUseCase) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadItems() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
getItemsUseCase().onSuccess { items ->
_uiState.update { it.copy(isLoading = false, items = items) }
}.onError { error ->
_uiState.update { it.copy(isLoading = false, errorMessage = error.toUserMessage()) }
}
}
}
}
// Extension — localize error strings here, not in the domain
fun AppError.toUserMessage(): String = when (this) {
is AppError.Network.NoConnection -> "No internet connection. Please check your network."
is AppError.Network.Timeout -> "Request timed out. Please try again."
is AppError.Network.Unauthorized -> "Your session has expired. Please log in again."
is AppError.Network.ServerError -> "Server error ($code). Please try again later."
is AppError.Database.NotFound -> "Item not found."
is AppError.Validation.InvalidInput -> "Invalid $field: $reason"
else -> "Something went wrong. Please try again."
}
@Composable
fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
HomeContent(
uiState = uiState,
onRetry = viewModel::loadItems
)
}
@Composable
fun HomeContent(
uiState: HomeUiState,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxSize()) {
// Content
if (uiState.items.isNotEmpty()) {
ItemList(items = uiState.items)
}
// Loading overlay
if (uiState.isLoading) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
// Error state
uiState.errorMessage?.let { message ->
ErrorBanner(
message = message,
onRetry = onRetry,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
Not all errors are equal — classify them to drive the right UI response:
// Extend AppError with recoverability metadata
val AppError.isRecoverable: Boolean get() = when (this) {
is AppError.Network.NoConnection -> true // user can re-enable wifi
is AppError.Network.Timeout -> true // user can retry
is AppError.Network.ServerError -> code in 500..599 // server-side, worth retrying
is AppError.Network.Unauthorized -> false // must re-authenticate
is AppError.Database.ReadFailure -> false // data corruption — escalate
is AppError.Database.WriteFailure -> true // may succeed on retry
is AppError.Database.NotFound -> false // no point retrying
is AppError.Validation -> false // user input issue — don't retry automatically
is AppError.Unexpected -> false // unknown — treat as fatal
else -> false
}
val AppError.isFatal: Boolean get() = !isRecoverable
Use isFatal to decide whether to show a retry button or navigate to an error screen:
fun AppError.toUiAction(): ErrorAction = when {
isRecoverable -> ErrorAction.ShowRetry
this is AppError.Network.Unauthorized -> ErrorAction.NavigateToLogin
isFatal -> ErrorAction.ShowFatalDialog
else -> ErrorAction.ShowRetry
}
sealed class ErrorAction {
data object ShowRetry : ErrorAction()
data object NavigateToLogin : ErrorAction()
data object ShowFatalDialog : ErrorAction()
}
Handle 429 Too Many Requests separately from other server errors — back off and retry after the Retry-After header:
suspend fun <T> safeApiCall(call: suspend () -> T): Resource<T> {
return try {
Resource.Success(call())
} catch (e: ClientRequestException) {
val error = when (e.response.status) {
HttpStatusCode.Unauthorized -> AppError.Network.Unauthorized()
HttpStatusCode.TooManyRequests -> {
// Respect Retry-After header if present
val retryAfter = e.response.headers["Retry-After"]?.toLongOrNull() ?: 60L
delay(retryAfter * 1000L)
return safeApiCall(call) // single retry after back-off
}
HttpStatusCode.NotFound -> AppError.Database.NotFound
else -> AppError.Network.ServerError(e.response.status.value, e.response.toString())
}
Resource.Error(error)
}
// ... other catch blocks
}
In Ktor HttpRequestRetry, exclude 429 from default server-error retry (handle it manually above):
install(HttpRequestRetry) {
retryIf(maxRetries = 3) { _, response ->
response.status.value in 500..599 && response.status != HttpStatusCode.TooManyRequests
}
exponentialDelay(base = 2.0, maxDelayMs = 10_000, randomizationMs = 500)
}
Record non-fatal errors and add contextual breadcrumbs before sending to crash services:
interface ErrorReporter {
fun recordError(error: AppError, context: Map<String, String> = emptyMap())
fun addBreadcrumb(message: String, category: String = "app")
}
// androidMain
class FirebaseErrorReporter : ErrorReporter {
private val crashlytics = FirebaseCrashlytics.getInstance()
override fun recordError(error: AppError, context: Map<String, String>) {
// Attach context as custom keys — visible in Crashlytics dashboard
context.forEach { (key, value) -> crashlytics.setCustomKey(key, value) }
crashlytics.setCustomKey("error_type", error::class.simpleName ?: "Unknown")
if (error is AppError.Network.ServerError) {
crashlytics.setCustomKey("http_code", error.code)
}
if (error.isFatal) {
crashlytics.recordException(error)
} else {
crashlytics.log("Non-fatal error: ${error::class.simpleName}")
}
}
override fun addBreadcrumb(message: String, category: String) {
crashlytics.log("[$category] $message")
}
}
Use in the ViewModel — record before mapping to user message:
fun loadItems() {
viewModelScope.launch {
errorReporter.addBreadcrumb("Loading items", "home")
getItemsUseCase().onError { error ->
errorReporter.recordError(error, mapOf(
"screen" to "home",
"action" to "loadItems"
))
_uiState.update { it.copy(errorMessage = error.toUserMessage()) }
}
}
}
For use cases that should support retry (e.g., network-dependent operations):
// domain/util/RetryPolicy.kt — commonMain
suspend fun <T> withRetry(
times: Int = 3,
initialDelay: Long = 1000L,
factor: Double = 2.0,
block: suspend () -> Resource<T>
): Resource<T> {
var currentDelay = initialDelay
repeat(times - 1) {
val result = block()
if (result is Resource.Success || result is Resource.Error &&
result.error !is AppError.Network.NoConnection &&
result.error !is AppError.Network.Timeout) {
return result
}
delay(currentDelay)
currentDelay = (currentDelay * factor).toLong()
}
return block()
}