Skip to main content

Error Handling

Overview

Proper error handling ensures that applications gracefully handle failures and provide meaningful feedback to users. This document defines error handling patterns across iOS, Android, and Flutter.

Core Principle

Convert low-level errors into domain errors and surface user-safe messages at the UI.

Architecture Pattern

Errors flow through layers with progressive refinement:

  1. Infrastructure Layer: Platform-specific errors (HTTP, DB, OS)
  2. Data Layer: Maps infrastructure errors to domain errors
  3. Domain Layer: Defines application-specific error types
  4. Presentation Layer: Converts errors to user-facing messages
Infrastructure Error → Data Layer → Domain Error → Presentation → User Message
(404 Not Found) (NotFound) ("User not found")

Domain Error Types

Define a sealed/enum type representing all possible domain errors.

Swift - Domain Errors

// App/Features/Auth/Domain/Entities/AppError.swift
enum AppError: Error {
case networkUnavailable
case unauthorized
case notFound
case invalidInput(String)
case server(message: String)
case decoding
case unknown
}

extension AppError: LocalizedError {
var errorDescription: String? {
switch self {
case .networkUnavailable:
return "No internet connection. Please check your network."
case .unauthorized:
return "Your session has expired. Please log in again."
case .notFound:
return "The requested resource was not found."
case .invalidInput(let message):
return message
case .server(let message):
return message
case .decoding:
return "Failed to process server response."
case .unknown:
return "An unexpected error occurred. Please try again."
}
}
}

Kotlin - Domain Errors

// app/features/auth/domain/entity/AppError.kt
sealed class AppError : Throwable() {
data object NetworkUnavailable : AppError()
data object Unauthorized : AppError()
data object NotFound : AppError()
data class InvalidInput(val message: String) : AppError()
data class Server(val message: String) : AppError()
data object Decoding : AppError()
data object Unknown : AppError()
}

fun AppError.toUserMessage(): String = when (this) {
is AppError.NetworkUnavailable -> "No internet connection. Please check your network."
is AppError.Unauthorized -> "Your session has expired. Please log in again."
is AppError.NotFound -> "The requested resource was not found."
is AppError.InvalidInput -> message
is AppError.Server -> message
is AppError.Decoding -> "Failed to process server response."
is AppError.Unknown -> "An unexpected error occurred. Please try again."
}

Dart - Domain Errors

// lib/features/auth/domain/entities/app_error.dart
sealed class AppError implements Exception {
const AppError();

String toUserMessage();
}

class NetworkUnavailable extends AppError {
const NetworkUnavailable();


String toUserMessage() => 'No internet connection. Please check your network.';
}

class Unauthorized extends AppError {
const Unauthorized();


String toUserMessage() => 'Your session has expired. Please log in again.';
}

class NotFound extends AppError {
const NotFound();


String toUserMessage() => 'The requested resource was not found.';
}

class InvalidInput extends AppError {
final String message;
const InvalidInput(this.message);


String toUserMessage() => message;
}

class ServerError extends AppError {
final String message;
const ServerError(this.message);


String toUserMessage() => message;
}

class DecodingError extends AppError {
const DecodingError();


String toUserMessage() => 'Failed to process server response.';
}

class UnknownError extends AppError {
const UnknownError();


String toUserMessage() => 'An unexpected error occurred. Please try again.';
}

Error Mapping in Data Layer

Convert infrastructure errors to domain errors.

iOS - Error Mapping

// App/Features/Auth/Data/Repositories/AuthRepositoryImpl.swift
final class AuthRepositoryImpl: AuthRepository {
private let apiClient: APIClient

func login(email: String, password: String) async throws -> TokenPair {
do {
let dto: TokenDTO = try await apiClient.request(.login(email: email, password: password))
return dto.toDomain()
} catch let error as APIError {
throw mapAPIError(error)
} catch {
throw AppError.unknown
}
}

private func mapAPIError(_ error: APIError) -> AppError {
switch error {
case .unauthorized:
return .unauthorized
case .notFound:
return .notFound
case .serverError(let message):
return .server(message: message)
case .networkError:
return .networkUnavailable
case .decodingError:
return .decoding
case .unknown:
return .unknown
}
}
}

Android - Error Mapping

// app/features/auth/data/repository/AuthRepositoryImpl.kt
class AuthRepositoryImpl @Inject constructor(
private val apiService: ApiService
) : AuthRepository {

override suspend fun login(email: String, password: String): Result<TokenPair> {
return try {
val dto = apiService.login(LoginRequest(email, password))
Result.success(dto.toDomain())
} catch (e: Exception) {
Result.failure(mapException(e))
}
}

private fun mapException(e: Exception): AppError {
return when (e) {
is HttpException -> when (e.code()) {
401 -> AppError.Unauthorized
404 -> AppError.NotFound
in 500..599 -> AppError.Server(e.message() ?: "Server error")
else -> AppError.Unknown
}
is IOException -> AppError.NetworkUnavailable
is JsonDataException -> AppError.Decoding
else -> AppError.Unknown
}
}
}

Flutter - Error Mapping

// lib/features/auth/data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
final AuthApi _api;

const AuthRepositoryImpl(this._api);


Future<TokenPair> login(String email, String password) async {
try {
final dto = await _api.login(email, password);
return dto.toDomain();
} on DioException catch (e) {
throw _mapDioException(e);
} catch (e) {
throw const UnknownError();
}
}

AppError _mapDioException(DioException e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError) {
return const NetworkUnavailable();
}

final statusCode = e.response?.statusCode;
return switch (statusCode) {
401 => const Unauthorized(),
404 => const NotFound(),
>= 500 && < 600 => ServerError(e.message ?? 'Server error'),
_ => const UnknownError(),
};
}
}

Error Handling in Presentation Layer

iOS - ViewModel Error Handling

// App/Features/Auth/Presentation/Screens/Login/LoginViewModel.swift
@MainActor
final class LoginViewModel: ObservableObject {
@Published private(set) var errorMessage: String?
@Published private(set) var isLoading = false

private let loginUseCase: LoginUseCase

init(loginUseCase: LoginUseCase) {
self.loginUseCase = loginUseCase
}

func login(email: String, password: String) async {
isLoading = true
errorMessage = nil

do {
let user = try await loginUseCase.execute(email: email, password: password)
// Navigate to home
} catch let error as AppError {
errorMessage = error.localizedDescription
} catch {
errorMessage = AppError.unknown.localizedDescription
}

isLoading = false
}
}

// Usage in View
struct LoginView: View {
@StateObject private var viewModel: LoginViewModel

var body: some View {
VStack {
// Form fields...

Button("Login") {
Task {
await viewModel.login(email: email, password: password)
}
}

if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.foregroundColor(.red)
}
}
}
}

Android - ViewModel Error Handling

// app/features/auth/presentation/screen/login/LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {

private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()

fun login(email: String, password: String) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)

loginUseCase(email, password)
.onSuccess { user ->
_state.value = LoginState(user = user)
}
.onFailure { error ->
val message = if (error is AppError) {
error.toUserMessage()
} else {
AppError.Unknown.toUserMessage()
}

_state.value = _state.value.copy(
loading = false,
error = message
)
}
}
}
}

// State
data class LoginState(
val user: User? = null,
val loading: Boolean = false,
val error: String? = null
)

// Usage in Compose
@Composable
fun LoginScreen(viewModel: LoginViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()

Column {
// Form fields...

Button(onClick = { viewModel.login(email, password) }) {
Text("Login")
}

state.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error
)
}
}
}

Flutter - Bloc Error Handling

// lib/features/auth/presentation/login/login_bloc.dart
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final LoginUseCase _loginUseCase;

LoginBloc({required LoginUseCase loginUseCase})
: _loginUseCase = loginUseCase,
super(const LoginInitial()) {
on<LoginSubmitted>(_onLoginSubmitted);
}

Future<void> _onLoginSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
emit(const LoginLoading());

try {
final user = await _loginUseCase(event.email, event.password);
emit(LoginSuccess(user));
} on AppError catch (e) {
emit(LoginFailure(e.toUserMessage()));
} catch (e) {
emit(LoginFailure(const UnknownError().toUserMessage()));
}
}
}

// States
sealed class LoginState {
const LoginState();
}

class LoginInitial extends LoginState {
const LoginInitial();
}

class LoginLoading extends LoginState {
const LoginLoading();
}

class LoginSuccess extends LoginState {
final User user;
const LoginSuccess(this.user);
}

class LoginFailure extends LoginState {
final String message;
const LoginFailure(this.message);
}

// Usage in Widget
class LoginPage extends StatelessWidget {

Widget build(BuildContext context) {
return BlocConsumer<LoginBloc, LoginState>(
listener: (context, state) {
if (state is LoginFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
return Column(
children: [
// Form fields...

ElevatedButton(
onPressed: state is! LoginLoading
? () => context.read<LoginBloc>().add(
LoginSubmitted(email: email, password: password),
)
: null,
child: const Text('Login'),
),
],
);
},
);
}
}

Result Type Pattern

For operations that can fail, consider using a Result type instead of throwing exceptions.

Swift - Result Type

func fetchUser(id: String) async -> Result<User, AppError> {
do {
let user = try await apiClient.request(.user(id: id))
return .success(user)
} catch let error as AppError {
return .failure(error)
} catch {
return .failure(.unknown)
}
}

// Usage
let result = await fetchUser(id: "123")
switch result {
case .success(let user):
print("User: \(user.name)")
case .failure(let error):
print("Error: \(error.localizedDescription)")
}

Kotlin - Result Type

Kotlin has built-in Result<T> type.

suspend fun fetchUser(id: String): Result<User> {
return try {
val user = apiService.getUser(id)
Result.success(user)
} catch (e: Exception) {
Result.failure(mapException(e))
}
}

// Usage
fetchUser("123")
.onSuccess { user -> println("User: ${user.name}") }
.onFailure { error -> println("Error: $error") }

Dart - Custom Result Type

sealed class Result<T> {
const Result();
}

class Success<T> extends Result<T> {
final T value;
const Success(this.value);
}

class Failure<T> extends Result<T> {
final AppError error;
const Failure(this.error);
}

// Usage
Future<Result<User>> fetchUser(String id) async {
try {
final user = await api.getUser(id);
return Success(user);
} on AppError catch (e) {
return Failure(e);
} catch (e) {
return Failure(const UnknownError());
}
}

// Pattern matching
final result = await fetchUser('123');
switch (result) {
case Success(:final value):
print('User: ${value.name}');
case Failure(:final error):
print('Error: ${error.toUserMessage()}');
}

Validation Errors

For input validation, return specific validation errors.

// Swift
enum ValidationError: Error {
case invalidEmail
case passwordTooShort
case passwordMismatch
}

func validateEmail(_ email: String) throws {
guard email.contains("@") && email.contains(".") else {
throw ValidationError.invalidEmail
}
}
// Kotlin
sealed class ValidationError : AppError() {
data object InvalidEmail : ValidationError()
data object PasswordTooShort : ValidationError()
data object PasswordMismatch : ValidationError()
}

fun validateEmail(email: String): Result<Unit> {
return if (email.contains("@") && email.contains(".")) {
Result.success(Unit)
} else {
Result.failure(ValidationError.InvalidEmail)
}
}
// Dart
sealed class ValidationError extends AppError {
const ValidationError();
}

class InvalidEmail extends ValidationError {
const InvalidEmail();

@override
String toUserMessage() => 'Please enter a valid email address.';
}

void validateEmail(String email) {
if (!email.contains('@') || !email.contains('.')) {
throw const InvalidEmail();
}
}

Error Logging

Always log errors for debugging, but never expose sensitive information.

// iOS
import OSLog

let logger = Logger(subsystem: "com.myapp", category: "network")

do {
let user = try await fetchUser(id: id)
} catch {
logger.error("Failed to fetch user: \(error.localizedDescription)")
// Never log sensitive data like tokens or passwords
}
// Android
import timber.log.Timber

try {
val user = fetchUser(id)
} catch (e: AppError) {
Timber.e(e, "Failed to fetch user")
// Never log sensitive data
}
// Flutter
import 'package:logger/logger.dart';

final logger = Logger();

try {
final user = await fetchUser(id);
} on AppError catch (e) {
logger.e('Failed to fetch user', error: e);
// Never log sensitive data
}

Summary

Error Handling Best Practices:

  1. Define domain-specific error types
  2. Map infrastructure errors at the data layer boundary
  3. Provide user-friendly messages in the presentation layer
  4. Log errors for debugging (without sensitive data)
  5. Use Result types for explicit error handling
  6. Validate inputs early and fail fast
  7. Never expose internal errors to users
PlatformError TypeError PropagationUser Messages
iOSenum Errorthrows / ResultLocalizedError protocol
Androidsealed class ThrowableResult<T>Extension function
Fluttersealed class Exceptionthrows / Custom ResultMethod on error type