Error Propagation — механизм распространения ошибки вверх по стеку вызовов от места возникновения до обработчика. Когда функция не может обработать ошибку самостоятельно, она передаёт её вызывающей стороне через исключение (exception), throws-декларацию или Return Type. Правильная реализация propagation критична для стабильности мобильных приложений: необработанные или некорректно переданные ошибки приводят к крашам. По данным Apple Swift Documentation (2026), автоматическая propagation через throws в Swift позволяет передавать ошибку на любой уровень без boilerplate-кода.
Главное
Error Propagation (распространение ошибки) — это процесс передачи объекта ошибки от функции, где она возникла, вверх по цепочке вызовов до ближайшего подходящего обработчика. Представьте стек вызовов: ViewController вызывает ViewModel, ViewModel вызывает Repository, Repository вызывает API. Если API возвращает ошибку сети, она должна пройти через Repository и ViewModel к ViewController, который покажет сообщение пользователю. Каждая промежуточная функция решает: обработать ошибку или передать дальше (propagate).
Существует два подхода к propagation: автоматический и ручной. При автоматическом подходе (Swift throws, Java checked exceptions) компилятор принуждает разработчика либо обработать ошибку, либо декларировать propagation в сигнатуре. При ручном подходе (Result Type, Kotlin Try) ошибка передаётся как значение — разработчик явно пишет код для передачи или трансформации ошибки. По данным Kotlin Result Docs (2026), Result<T> в Kotlin не предназначен для propagation через границы функций напрямую — его нужно трансформировать или обрабатывать на каждом уровне, что делает propagation более осознанным, но и более многословным.
Выбор подхода зависит от архитектуры приложения и языка. В Swift доминирует автоматическая propagation через throws, в Kotlin — микс исключений (для неожиданных ошибок) и Result-подобных контейнеров (для ожидаемых). Важно понимать: propagation — это не цель, а необходимость. Идеальная архитектура минимизирует глубину propagation, обрабатывая ошибки на максимально низком уровне, где есть достаточно контекста для принятия решения.
В Swift propagation через throws происходит автоматически: если функция A с throws вызывает функцию B с throws, и A не обрабатывает ошибку B в do-catch, ошибка автоматически передаётся вызывающей стороне A. Это избавляет от boilerplate-кода, характерного для Java checked exceptions, где throws нужно декларировать в каждом методе цепочки. Swift использует принцип «одна throws-функция в цепочке = всё цепочка становится throws, если не обрабатывать на промежуточных уровнях».
struct UserRepository {
func fetchUser(id: Int) throws -> User {
let data = try networkService.request(path: "/users/\(id)")
return try parseUser(from: data)
}
}
class UserViewModel {
let repo = UserRepository()
func loadUser(id: Int) throws -> User {
return try repo.fetchUser(id: id)
}
}
// ViewController — конечный обработчик
func onButtonTap() {
let vm = UserViewModel()
do {
let user = try vm.loadUser(id: 42)
updateUI(user)
} catch {
showError("Failed to load user")
}
}
Цепочка propagation: networkService.request -> fetchUser -> loadUser -> onButtonTap. Каждая промежуточная функция помечена throws и не содержит do-catch — ошибка автоматически передаётся наверх. ViewController onButtonTap — конечный обработчик с do-catch. Если бы ViewModel решила трансформировать ошибку (завернуть в другой тип), она могла бы использовать do-catch и новый throw. Автоматическая propagation сокращает код: Repository не нужно знать, как обрабатывать ошибку — это ответственность ViewController, который имеет доступ к UI для показа сообщения пользователю.
В Kotlin propagation через исключения не требует декларации throws в сигнатуре (все исключения unchecked). Исключение автоматически поднимается по стеку, пока не встретит try-catch. Однако отсутствие throws в сигнатуре делает propagation неявной: разработчик не видит из сигнатуры функции, что она может выбросить исключение. Это плюс (меньше boilerplate) и минус (легче забыть обработать) одновременно. Kotlin решает эту проблему через соглашения и архитектурные паттерны, а не через язык.
class UserRepository(
private val api: ApiService,
private val db: Database
) {
suspend fun getUser(id: String): User {
return try {
api.fetchUser(id)
} catch (e: IOException) {
db.getCachedUser(id) ?: throw AppException("User unavailable")
}
}
}
class UserViewModel(private val repo: UserRepository) {
private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
val state: StateFlow<UiState<User>> = _state
fun loadUser(id: String) {
viewModelScope.launch {
try {
val user = repo.getUser(id)
_state.value = UiState.Success(user)
} catch (e: AppException) {
_state.value = UiState.Error(e.message ?: "Unknown")
}
}
}
}
В Repository propagation с трансформацией: при IOException (сеть недоступна) функция пытается получить закэшированные данные из БД. Если кэш пуст, выбрасывает AppException — propagation продолжается уже с новым типом ошибки. ViewModel перехватывает AppException и транслирует в UiState.Error — ошибка не идёт дальше, propagation завершена на уровне UI-слоя. Kotlin Coroutines добавляют особенности: исключения в launch автоматически распространяются через CoroutineExceptionHandler, а в async — только при вызове await(). Это важно учитывать при проектировании propagation корутинах — SupervisorJob предотвращает отмену родительской корутины при ошибке в дочерней.
Альтернатива исключениям — propagation через тип-контейнер, который передаёт успех или ошибку как значение. В этом подходе функция возвращает не значение, а обёртку: Result<T, E> в Swift, Result<T> в Kotlin, Either<L, R> в Dart (из пакета fpdart или dartz). Ошибка не раскручивает стек — она просто лежит в контейнере, и следующий уровень решает, что с ней делать. Это делает propagation более явной и контролируемой.
data class HttpResult<out T>(
val data: T?,
val error: AppError?
) {
val isSuccess: Boolean get() = data != null
val isError: Boolean get() = error != null
}
sealed class AppError {
data class Network(val message: String) : AppError()
data class Auth(val message: String) : AppError()
}
fun fetchUser(id: String): HttpResult<User> {
return try {
val response = api.get("/users/$id")
HttpResult(data = parseUser(response), error = null)
} catch (e: IOException) {
HttpResult(data = null, error = AppError.Network("No internet"))
}
}
HttpResult<T> — простой контейнер с полями data и error. Sealed class AppError определяет типы ошибок (Network, Auth). Функция fetchUser возвращает HttpResult, propagation не требует раскрутки стека — вызывающая сторона просто проверяет isSuccess/isError. Такой подход особенно полезен в Clean Architecture, где каждый слой (data, domain, presentation) может трансформировать ошибку: IOError -> DomainError -> UiError. Propagation через контейнер делает эти трансформации явными и тестируемыми, в отличие от исключений, где цепочка преобразований не видна в сигнатурах функций.
Одно из ключевых решений в проектировании обработки ошибок — выбор между propagation (передать выше) и handling (обработать здесь). Правило принятия решения: обрабатывай ошибку на том уровне, где есть достаточно контекста для meaningful действия. Если у тебя есть доступ к UI — покажи сообщение пользователю. Если есть доступ к кэшу — попробуй восстановиться. Если нет ни того, ни другого — propagate.
| Сценарий | Действие | Обоснование |
|---|---|---|
| Ошибка сети в Repository | Propagate | Repository не знает, хочет ли пользователь повторить запрос |
| Ошибка парсинга в Repository | Обработать (вернуть default) | Repository знает формат, может вернуть fallback-значение |
| Таймаут в ViewModel | Обработать (UiState.Error) | ViewModel управляет UiState, знает, как транслировать ошибку |
| Ошибка авторизации в Interceptor | Обработать (refresh token) | Interceptor имеет доступ к токенам и может восстановить сессию |
| Неизвестная ошибка в UseCase | Propagate | UseCase не имеет UI-контекста — только бизнес-логика |
Золотое правило: минимум propagation, максимум handling на нижних уровнях. Если Repository может восстановиться из кэша — он должен это сделать, не передавая ошибку выше. Если ViewModel может показать Snackbar — пусть покажет, не требуя от ViewController дополнительного кода. Каждый уровень propagation увеличивает связанность и усложняет тестирование. По данным Google Android Architecture Guide (2026), рекомендуется минимизировать propagation через границы слоёв, используя sealed class UiState для представления всех возможных состояний (Loading, Success, Error) на уровне ViewModel, и не передавать исключения в UI-слой напрямую.
Некорректная propagation — источник трудно отлавливаемых багов в мобильных приложениях. Рассмотрим пять основных проблем, с которыми сталкиваются разработчики, и способы их решения.
Самая частая проблема: при propagation исключение перехватывается, логируется и выбрасывается новое без original exception. Разработчик теряет StackTrace и не может понять, где именно возникла ошибка. В Swift используйте error chaining: throw MyError(context: originalError). В Kotlin: throw AppException(cause = originalException). В Dart: throw AppException(message, originalException). Никогда не создавайте новое исключение без передачи cause/underlyingError.
catch (e: Exception) { /* ничего */ } — антипаттерн, который приводит к тому, что приложение продолжает работу в некорректном состоянии. Если вы уверены, что ошибку можно игнорировать — добавьте комментарий с обоснованием. В Swift для опционального игнорирования используйте try? (ошибка -> nil). В Kotlin — Result<T>.onFailure { /* log */ }. Не глушите исключения без логирования.
Если ошибка проходит через 5+ уровней без обработки, архитектура требует пересмотра. Каждый уровень propagation — это зависимость от сигнатуры throws нижележащих функций. Решение: используйте Failure-контейнеры (sealed class Result { Success, Error }) на границах слоёв, чтобы сделать propagation явной и ограниченной. Чем меньше цепочка propagation, тем проще тестировать и отлаживать код.
В Kotlin Coroutines исключение в launch по умолчанию отменяет родительскую корутину и всех siblings (детей того же scope). Если одна задача из 10 параллельных упала, остальные 9 будут отменены, что часто нежелательно. Используйте SupervisorJob или supervisorScope для изоляции ошибок: ошибка в одном child не отменяет siblings. ViewModelScope использует SupervisorJob по умолчанию, что спасает от этой проблемы в Android.
В callback-based API ошибка часто передаётся как параметр колбэка. Если callback не обрабатывает ошибку (или обрабатывает некорректно), propagation становится неявной и легко теряется. Решение: мигрируйте на async/await (Swift) или корутины (Kotlin), где propagation работает через стандартные механизмы try-catch. Если callback неизбежен — используйте Either<Error, T> или Result<T> для принудительной обработки обоих кейсов.
Часто задаваемые вопросы
Throw — это одноразовое действие выброса исключения. Error Propagation — весь процесс передачи ошибки через несколько уровней стека, от throw до catch. Propagation включает throw, автоматическую или ручную передачу через промежуточные функции и финальную обработку. Это более широкое понятие, описывающее жизненный цикл ошибки.
Используйте mock-объекты, которые выбрасывают исключения в заданных сценариях. Проверяйте, что функция корректно propagate или обрабатывает ошибку через assertThrows (Kotlin/JUnit) или XCTAssertThrowsError (Swift/XCTest). Для Result-based propagation проверяйте isSuccess/isError и значения в обоих кейсах.
Result propagation предпочтительна для ожидаемых ошибок (неверные данные, бизнес-правила) в рамках одной архитектурной границы. Исключения лучше для неожиданных ошибок (потеря сети, ошибки ввода-вывода), которые должны быть обработаны на высоком уровне. Результат с ошибкой не прерывает поток выполнения, исключение — прерывает.
В Kotlin Coroutines исключение в launch автоматически propagate через CoroutineScope с отменой siblings. Используйте supervisorScope или SupervisorJob для изоляции: ошибка в одной корутине не отменяет другие. Для async ошибку нужно явно обработать через try-catch при вызове await(), иначе она будет проглочена.
Идеальный конечный обработчик — UI-слой (ViewController, Fragment/Composable). Только он имеет доступ к пользовательскому интерфейсу и может показать сообщение, Snackbar или диалог. Промежуточные слои (Repository, UseCase, ViewModel) propagate ошибку, трансформируя при необходимости в более абстрактный доменный тип.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также