MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
This post shows how to replace exception-based error handling with Kotlin's Result type and sealed class hierarchies, including composition patterns and repository integration.
On this page
Exceptions are the lazy way to handle errors. They hide failure paths in stack traces, force try-catch everywhere, and make control flow invisible. Kotlin gives us better tools.
I've shipped 22 apps from Bangkok over 13 years. The ones that survive production longest are the ones where errors are values, not control flow. This post walks through the pattern I use in every codebase now.
Exceptions break referential transparency. A function signature
fun fetchUser(id: String): UserIOExceptionHttpExceptionJsonParseException| Aspect | Exceptions | Result Types |
|---|---|---|
| Visibility | Hidden in docs/runtime | Explicit in signature |
| Composition | try-catch nesting | map/flatMap chaining |
| Exhaustiveness | Compiler can't verify | Sealed classes enforce handling |
| Testing | Requires exception throwing | Plain value assertions |
| Stack traces | Expensive allocation | Zero-cost values |
[!WARNING] Exceptions allocate stack traces on every throw. In hot paths—network retries, database migrations, image decoding—this creates measurable GC pressure. Result types allocate a single object.
Kotlin's standard library includes
kotlin.Result// kotlin.Result is inline and value-based
inline fun <T> Result<T>.getOrThrow(): T =
fold(onFailure = { throw it }, onSuccess = { it })
inline fun <T> Result<T>.getOrNull(): T? =
fold(onFailure = { null }, onSuccess = { it })
inline fun <T> Result<T>.getOrDefault(defaultValue: T): T =
fold(onFailure = { defaultValue }, onSuccess = { it })But
kotlin.ResultThrowablesealed interface Result<out T> {
data class Success<out T>(val value: T) : Result<T>
data class Failure(val error: AppError) : Result<Nothing>
fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(value))
is Failure -> this
}
fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
is Success -> transform(value)
is Failure -> this
}
fun fold<R>(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) {
is Success -> onSuccess(value)
is Failure -> onFailure(error)
}
}
sealed interface AppError {
data class Network(val cause: Throwable?) : AppError
data class Http(val statusCode: Int, val body: String?) : AppError
data class Parsing(val cause: Throwable) : AppError
data class NotFound(val resource: String) : AppError
data class Unauthorized : AppError
data class Validation(val fieldErrors: Map<String, List<String>>) : AppError
data class Unknown(val cause: Throwable) : AppError
}[!NOTE] The
functions oncodeinlineavoid boxing overhead. My sealed version isn't inline, but the allocation cost is one object per operation—negligible compared to network I/O.codekotlin.Result
The power of Result appears when chaining operations. Each step either transforms the value or short-circuits with the first error.
class UserRepository @Inject constructor(
private val api: UserApi,
private val cache: UserCache,
private val mapper: UserMapper
) {
suspend fun getUser(id: String): Result<User> =
cache.get(id)
.flatMap { cached ->
if (cached.isFresh) Result.Success(cached.user)
else fetchAndCache(id)
}
.flatMap { apiUser ->
Result.runCatching { mapper.toDomain(apiUser) }
.mapFailure { AppError.Parsing(it) }
}
private suspend fun fetchAndCache(id: String): Result<User> =
api.getUser(id)
.map { Response.Success(it) }
.recover { Response.Failure(it) }
.flatMap { response ->
when (response) {
is Response.Success -> cache.put(response.body).map { response.body }
is Response.Failure -> Result.Failure(mapApiError(response.error))
}
}
private fun mapApiError(error: ApiError): AppError = when {
error is ApiError.Network -> AppError.Network(error.cause)
error is ApiError.Http -> AppError.Http(error.code, error.body)
else -> AppError.Unknown(error)
}
}[!TIP] Use
when the next step returnscodeflatMap. UsecodeResultwhen it returns a plain value. Mixing them up is the most common bug—codemapgives youcodemap { fetchUser(it) }.codeResult<Result<User>>
The ViewModel layer consumes Result without try-catch. UI state becomes a simple fold.
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState.Loading>
val uiState: StateFlow<UserUiState> = _uiState
fun loadUser(userId: String) {
viewModelScope.launch {
_uiState.value = UserUiState.Loading
val result = repository.getUser(userId)
_uiState.value = result.fold(
onSuccess = { UserUiState.Success(it) },
onFailure = { UserUiState.Error(it) }
)
}
}
fun retry(userId: String) = loadUser(userId)
}
sealed interface UserUiState {
data class Loading : UserUiState
data class Success(val user: User) : UserUiState
data class Error(val error: AppError) : UserUiState
}The Compose screen handles each state explicitly—no surprise crashes.
@Composable
fun UserScreen(viewModel: UserViewModel, userId: String) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UserUiState.Loading -> LoadingSpinner()
is UserUiState.Success -> UserProfile(user = uiState.user)
is UserUiState.Error -> ErrorView(
error = uiState.error,
onRetry = { viewModel.retry(userId) }
)
}
}
@Composable
fun ErrorView(error: AppError, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = error.userMessage, style = MaterialTheme.typography.bodyLarge)
Spacer(modifier = Modifier.padding(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}
val AppError.userMessage: String
get() = when (this) {
is AppError.Network -> "No internet connection. Check your network."
is AppError.Http -> when (statusCode) {
401 -> "Session expired. Please sign in again."
404 -> "User not found."
500..599 -> "Server error. Please try again later."
else -> "Request failed ($statusCode)."
}
is AppError.Parsing -> "Data format error. Please update the app."
is AppError.NotFound -> "$resource not found."
is AppError.Unauthorized -> "Please sign in to continue."
is AppError.Validation -> "Please check the highlighted fields."
is AppError.Unknown -> "Something went wrong. Please try again."
}[!IMPORTANT] Error messages live in the domain layer (
), not the UI. This keeps copy consistent across screens and testable without rendering.codeAppError.userMessage
Tests become pure function assertions. No mocking exception throwing, no
expectedExceptionclass UserRepositoryTest {
private val api = mockk<UserApi>()
private val cache = mockk<UserCache>()
private val mapper = mockk<UserMapper>()
private val repository = UserRepository(api, cache, mapper)
@Test
fun `getUser returns cached user when fresh`() = runTest {
val cached = User(id = "1", name = "Test")
coEvery { cache.get("1") } returns Result.Success(CachedUser(cached, true))
val result = repository.getUser("1")
assertTrue(result is Result.Success)
assertEquals(cached, result.getOrNull())
verify(exactly = 0) { api.getUser(any()) }
}
@Test
fun `getUser fetches from API when cache stale`() = runTest {
val apiUser = ApiUser(id = "1", name = "Test")
val domainUser = User(id = "1", name = "Test")
coEvery { cache.get("1") } returns Result.Success(CachedUser(domainUser, false))
coEvery { api.getUser("1") } returns Result.Success(Response.Success(apiUser))
coEvery { mapper.toDomain(apiUser) } returns domainUser
coEvery { cache.put(domainUser) } returns Result.Success(Unit)
val result = repository.getUser("1")
assertTrue(result is Result.Success)
assertEquals(domainUser, result.getOrNull())
}
@Test
fun `getUser returns parsing error when mapper throws`() = runTest {
val apiUser = ApiUser(id = "1", name = "Test")
coEvery { cache.get("1") } returns Result.Success(CachedUser(User("1", ""), false))
coEvery { api.getUser("1") } returns Result.Success(Response.Success(apiUser))
coEvery { mapper.toDomain(apiUser) } throws JsonParseException("bad json")
val result = repository.getUser("1")
assertTrue(result is Result.Failure)
assertTrue(result.fold(onSuccess = { false }, onFailure = { it is AppError.Parsing }))
}
}Result types add ceremony. Not every function needs them.
| Use Exceptions | Use Result Types |
|---|---|
| Programming bugs (null checks, index bounds) | Expected failures (network, parsing, validation) |
| Library internals where caller can't recover | Public API boundaries |
| Scripts and one-off tools | Business logic, repositories, use cases |
| Interop with Java APIs that throw | Coroutine boundaries |
[!NOTE] Kotlin's
is perfect for interop.codeResultconverts any exception tocoderunCatching { javaLib.call() }instantly. Wrap at the boundary, then use your domaincodeResultinside.codeResult
Result<Success, Failure<AppError>>AppError.userMessagerunCatchingSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read