Android System Design Interview Questions
Design an error handling structure.
Tier: CommonDifficulty: Medium
The goal of an error handling structure is that every layer of the app deals with one consistent error type. A ViewModel should never have to know whether a failure came from a network exception, a database exception, or a business-rule rejection three layers down.
What I'd clarify first
- Does the UI need to distinguish between error types, a retry button for a timeout but a different message for "item out of stock", or is one generic error state enough.
- Are errors expected to be localized and user-facing, or is this purely for logging and the UI shows generic copy regardless.
- Is the codebase on coroutines, or still callback-based in places, because a callback API needs the same mapping applied before the callback fires rather than at a return.
The shape of it
A sealed type modelling every category of failure the app can react to differently, rather than a single generic Exception that call sites have to inspect to figure out what happened. The wrapper is called Outcome rather than Result, because Kotlin's standard library already owns that name and shadowing it in your own code is a trap worth stepping around.
sealed interface AppError {
data class Network(val cause: Throwable) : AppError
// Its own case, not a flavour of Network. One is worth retrying quietly,
// the other is worth telling the user they are offline.
data class Timeout(val cause: Throwable) : AppError
data class Server(val code: Int, val message: String) : AppError
data class Validation(val field: String, val reason: String) : AppError
data object Unauthorized : AppError
data class Unknown(val cause: Throwable) : AppError
}
sealed interface Outcome<out T> {
data class Success<T>(val value: T) : Outcome<T>
data class Failure(val error: AppError) : Outcome<Nothing>
}
Every layer below the UI catches its own raw exceptions and maps them into this type at the boundary. Nothing above that boundary ever sees an IOException or a SQLiteException. A repository catches OkHttp's IOException and returns AppError.Network, catches a 422 and returns AppError.Validation, and so on.
One catch has to come first, and leaving it out is the best known bug in this pattern.
suspend fun getUser(id: String): Outcome<User> = boundary { api.getUser(id) }
// inside boundary
try {
Outcome.Success(call())
} catch (e: CancellationException) {
// A closing screen is not a failure. Catching Exception on its own turns a
// cancelled coroutine into an error the user sees on the way out, and the
// cancellation never reaches the parent, which breaks structured concurrency.
throw e
} catch (e: Exception) {
Outcome.Failure(e.toAppError())
}
Java has the same rule under a different name. A call that comes back InterruptedException restores the interrupt flag and unwinds, rather than being mapped into an error somebody is meant to read.
At the top, the ViewModel matches on the sealed type exhaustively and maps each case to the UI state that is actually appropriate. A retry affordance for Network, a specific message for Validation, a generic fallback for Unknown.
How the error reaches the screen
Picking the wrong shape here is the bug people actually ship. A blocking error, where the screen has nothing to show, belongs in the state, so it survives rotation and re-renders correctly. A transient error, a snackbar or a toast, is a one-shot event the UI consumes and clears. Leave it in the state and the same toast fires again on every recomposition and every rotation.
The code
Look at the presenter in each language. The Kotlin when is exhaustive over a sealed interface, so adding a case to AppError breaks compilation in every place a failure is turned into something a user sees. The Java version is an if chain that keeps compiling, keeps running, and quietly falls through to the generic branch. Same taxonomy, same design, one enforced by the compiler and the other by a style guide. Kotlin also makes Outcome.Failure an Outcome<Nothing>, so mapping over a success needs no cast and no branch that cannot happen.
Java
com.androidinterview.errors.data.Boundary.java
package com.androidinterview.errors.data;
import com.androidinterview.errors.error.AppError;
import com.androidinterview.errors.error.Outcome;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
// The layer boundary, and the one rule that makes the whole structure work.
// Every raw exception is caught and mapped exactly here, so nothing above the
// repository ever sees an IOException, an HttpException or a SQLiteException.
// A ViewModel never learns whether the data came from Retrofit or from Room.
//
// This is also the boilerplate people skip under deadline pressure, which is
// how a raw constraint violation message ends up rendered in front of a user.
// Writing it once, as a helper every repository method wraps, is what stops
// that from being a matter of discipline.
public final class Boundary {
// Stands in for Retrofit's HttpException so the sample compiles on its
// own. The shape is the same, a status code and a message.
public static final class HttpException extends RuntimeException {
private final int code;
private final String field;
public HttpException(int code, String message) {
this(code, message, "");
}
// A 422 body names the field it rejected, and that name is the only
// thing in this whole taxonomy the client shows to a user verbatim.
public HttpException(int code, String message, String field) {
super(message);
this.code = code;
this.field = field;
}
public int code() {
return code;
}
public String field() {
return field;
}
}
private Boundary() {
}
public static <T> Outcome<T> of(Callable<T> call) {
try {
return new Outcome.Success<>(call.call());
} catch (InterruptedException e) {
// Cancellation is not a failure, and this is Java's version of it.
// The work was abandoned, so restore the flag and let the caller
// unwind rather than handing a screen that is already closing an
// error to display. Kotlin has the same rule with
// CancellationException, which a catch of Exception swallows.
Thread.currentThread().interrupt();
throw new CancellationException("cancelled");
} catch (CancellationException e) {
throw e;
} catch (Exception e) {
return new Outcome.Failure<>(map(e));
}
}
public static AppError map(Exception e) {
// The timeout test comes first because SocketTimeoutException is an
// IOException, so the other order would make this branch unreachable.
// The two are separate cases because one is worth retrying quietly and
// the other is worth telling the user about.
if (e instanceof SocketTimeoutException) return new AppError.Timeout(e);
if (e instanceof IOException) return new AppError.Network(e);
if (e instanceof HttpException http) {
if (http.code() == 401) return new AppError.Unauthorized();
// 422 is where Validation comes from. The server knows the rule and
// the client does not, so the field name travels with the error.
if (http.code() == 422) return new AppError.Validation(http.field(), http.getMessage());
return new AppError.Server(http.code(), http.getMessage());
}
return new AppError.Unknown(e);
}
}
com.androidinterview.errors.error.AppError.java
package com.androidinterview.errors.error;
// Every failure the app can react to differently, and nothing else. The
// taxonomy is deliberately small, because a case exists here only if some
// screen genuinely does something different with it. A category nobody
// branches on is a category that should not be a type.
public sealed interface AppError {
record Network(Throwable cause) implements AppError {
}
// A timeout is its own case and not a flavour of Network, because the two
// are worth different things. A timeout is worth retrying quietly, and
// being offline is worth telling the user about.
record Timeout(Throwable cause) implements AppError {
}
record Server(int code, String message) implements AppError {
}
record Validation(String field, String reason) implements AppError {
}
record Unauthorized() implements AppError {
}
record Unknown(Throwable cause) implements AppError {
}
// Retryability belongs on the error rather than on the call site, because
// whether a thing is worth trying again is a property of what went wrong.
// Every call site otherwise reinvents this, differently.
//
// This is the if chain problem again. Add a case to the interface and this
// method keeps compiling and quietly returns false for it.
default boolean retryable() {
if (this instanceof Network || this instanceof Timeout) return true;
if (this instanceof Server server) return server.code() >= 500 || server.code() == 429;
return false;
}
}
com.androidinterview.errors.error.Outcome.java
package com.androidinterview.errors.error;
import java.util.function.Function;
// Failure as part of the signature rather than as documentation. A method
// returning Outcome cannot be called without the caller deciding what to do
// when it fails, which is exactly the discipline that a thrown exception
// leaves to a code review.
public sealed interface Outcome<T> {
record Success<T>(T value) implements Outcome<T> {
}
record Failure<T>(AppError error) implements Outcome<T> {
}
default <R> Outcome<R> map(Function<T, R> mapper) {
if (this instanceof Success<T> success) return new Success<>(mapper.apply(success.value()));
return new Failure<>(((Failure<T>) this).error());
}
}
com.androidinterview.errors.ui.ErrorPresenter.java
package com.androidinterview.errors.ui;
import com.androidinterview.errors.error.AppError;
// The top of the structure. One place decides what a failure looks like, so
// three engineers in three sprints cannot answer what do I show when this call
// fails in three different ways, a toast, a silent log and a crash.
//
// In Java this is an if chain and nothing checks that it is complete. Add a
// case to AppError and this class still compiles, still runs, and quietly
// takes the last branch. That is the gap the Kotlin version closes, and it is
// worth naming out loud in the room rather than pretending the two are equal.
public final class ErrorPresenter {
public record UiError(String messageKey, boolean showRetry) {
}
private ErrorPresenter() {
}
public static UiError present(AppError error) {
if (error instanceof AppError.Network) {
return new UiError("error.offline", true);
}
// Same retry affordance as Network, different copy. Waiting on a slow
// network is not the same message as having no network at all.
if (error instanceof AppError.Timeout) {
return new UiError("error.timeout", true);
}
if (error instanceof AppError.Server server) {
return new UiError("error.server", server.retryable());
}
if (error instanceof AppError.Validation validation) {
// The only case carrying text worth showing as it is, because the
// server knows the rule and the client does not.
return new UiError(validation.reason(), false);
}
if (error instanceof AppError.Unauthorized) {
return new UiError("error.session.expired", false);
}
return new UiError("error.generic", false);
}
}
Kotlin
com.androidinterview.errors.data.Boundary.kt
package com.androidinterview.errors.data
import com.androidinterview.errors.error.AppError
import com.androidinterview.errors.error.Outcome
import java.io.IOException
import java.net.SocketTimeoutException
import kotlin.coroutines.cancellation.CancellationException
// Stands in for Retrofit's HttpException so the sample compiles on its own.
// The shape is the same, a status code and a message.
// A 422 body names the field it rejected, and that name is the only thing in
// this whole taxonomy the client shows to a user verbatim.
class HttpException(val code: Int, message: String, val field: String = "") : RuntimeException(message)
// The layer boundary, and the one rule that makes the whole structure work.
// Every raw exception is caught and mapped exactly here, so nothing above the
// repository ever sees an IOException, an HttpException or a SQLiteException,
// and a ViewModel never learns whether the data came from Retrofit or Room.
//
// This is also the boilerplate people skip under deadline pressure, which is
// how a raw constraint violation message ends up rendered in front of a user.
// One inline wrapper every repository method already has to use is what stops
// that from being a matter of discipline.
suspend inline fun <T> boundary(crossinline call: suspend () -> T): Outcome<T> =
try {
Outcome.Success(call())
} catch (e: CancellationException) {
// The one catch that has to come first. Cancelling the enclosing scope,
// a ViewModel cleared or a screen closed, throws through here, and a
// catch of Exception would turn that into a failure. The screen would
// show an error on its way out, and the cancellation would never reach
// the parent, which is structured concurrency broken.
throw e
} catch (e: Exception) {
Outcome.Failure(e.toAppError())
}
fun Throwable.toAppError(): AppError = when (this) {
// The timeout branch comes first because SocketTimeoutException is an
// IOException, so the other order would make it unreachable. The two are
// separate cases because one is worth retrying quietly and the other is
// worth telling the user about.
is SocketTimeoutException -> AppError.Timeout(this)
is IOException -> AppError.Network(this)
is HttpException -> when (code) {
401 -> AppError.Unauthorized
// 422 is where Validation comes from. The server knows the rule and the
// client does not, so the field name travels with the error.
422 -> AppError.Validation(field, message.orEmpty())
else -> AppError.Server(code, message.orEmpty())
}
else -> AppError.Unknown(this)
}
com.androidinterview.errors.error.AppError.kt
package com.androidinterview.errors.error
// Every failure the app can react to differently, and nothing else. The
// taxonomy is deliberately small, because a case belongs here only if some
// screen genuinely does something different with it. A category nobody
// branches on should not be a type.
sealed interface AppError {
data class Network(val cause: Throwable) : AppError
// A timeout is its own case and not a flavour of Network, because the two
// are worth different things. A timeout is worth retrying quietly, and
// being offline is worth telling the user about.
data class Timeout(val cause: Throwable) : AppError
data class Server(val code: Int, val message: String) : AppError
data class Validation(val field: String, val reason: String) : AppError
data object Unauthorized : AppError
data class Unknown(val cause: Throwable) : AppError
}
// Retryability is a property of what went wrong, so it lives on the error
// rather than at the call site. Every call site otherwise reinvents this,
// differently.
val AppError.retryable: Boolean
get() = when (this) {
is AppError.Network, is AppError.Timeout -> true
is AppError.Server -> code >= 500 || code == 429
is AppError.Validation, AppError.Unauthorized, is AppError.Unknown -> false
}
// Failure as part of the signature rather than as documentation. A function
// returning Outcome cannot be called without the caller deciding what happens
// when it fails, which is the discipline a thrown exception leaves to a code
// review. The variance annotation is what lets an Outcome of a subtype flow
// where the supertype is wanted, so no call site needs a cast.
sealed interface Outcome<out T> {
data class Success<T>(val value: T) : Outcome<T>
data class Failure(val error: AppError) : Outcome<Nothing>
}
// Failure is an Outcome of Nothing, so this maps without a cast and without a
// branch that cannot happen. That is the payoff of the variance above.
inline fun <T, R> Outcome<T>.map(transform: (T) -> R): Outcome<R> = when (this) {
is Outcome.Success -> Outcome.Success(transform(value))
is Outcome.Failure -> this
}
com.androidinterview.errors.ui.ErrorPresenter.kt
package com.androidinterview.errors.ui
import com.androidinterview.errors.error.AppError
import com.androidinterview.errors.error.retryable
data class UiError(val messageKey: String, val showRetry: Boolean)
// The top of the structure, and the reason Kotlin is the better language for
// this particular answer. The when is exhaustive over a sealed interface, so
// adding a case to AppError breaks this file at compile time and every other
// place a failure is turned into something a user sees.
//
// The Java version of this file is an if chain that keeps compiling when a new
// case appears and quietly falls through to the generic branch. Same design,
// same taxonomy, and one of them is enforced by the compiler while the other
// is enforced by a style guide. That is worth saying out loud in the room.
fun AppError.present(): UiError = when (this) {
is AppError.Network -> UiError("error.offline", showRetry = true)
// Same retry affordance as Network, different copy. Waiting on a slow
// network is not the same message as having no network at all.
is AppError.Timeout -> UiError("error.timeout", showRetry = true)
is AppError.Server -> UiError("error.server", showRetry = retryable)
// The only case carrying text worth showing as it is, because the server
// knows the rule and the client does not.
is AppError.Validation -> UiError(reason, showRetry = false)
AppError.Unauthorized -> UiError("error.session.expired", showRetry = false)
is AppError.Unknown -> UiError("error.generic", showRetry = false)
}
Tradeoffs I'd call out
- A rich sealed hierarchy vs a flat generic error. A detailed type per failure category lets the UI react precisely, retry here and a different message there. It is real design work up front to decide the taxonomy, and it grows every time a new kind of failure needs its own case. A flat "something went wrong" type is far less work and gives the UI nothing to work with.
- Mapping errors at every layer boundary vs letting raw exceptions bubble up. Mapping at each boundary keeps every layer decoupled from the ones below it, so a ViewModel never needs to know whether data came from Retrofit or Room. It does mean a mapping step at every repository method, which is boilerplate that is easy to skip under deadline pressure. Skipping it is exactly how a raw
SQLiteExceptionmessage ends up rendered in front of a user. Outcome-wrapped returns vs exceptions. Wrapping every fallible call makes failure an explicit part of the signature, so the compiler will not let you forget to handle it. Plain exceptions are less code at the call site, but they rely entirely on documentation and discipline to know what can go wrong and where it is caught.
What breaks at scale
The failure mode to design against here isn't a crash, it's inconsistency. Three engineers across three sprints answer "what do I do when this API call fails" three different ways. One shows a toast, one silently logs and shows nothing, one crashes on an unhandled case. A shared error type with an exhaustive sealed hierarchy, enforced by the compiler rather than a style guide, is what actually prevents that drift as a codebase and a team grow.
Watch