1. Полезно делить ошибки по слоям
Когда вы пишете небольшую программу, очень легко скатиться к философии «ловим Error и печатаем ERROR: \(error)». Это как лечить все болезни одной таблеткой: иногда даже помогает (обычно — случайно), но чаще портит жизнь. Проблема в том, что у разных типов ошибок разные причины и разные корректные реакции. Пустой заголовок книги — это одно, а «данные не сохранились» — совсем другое.
Swift, кстати, специально заставляет вас отмечать потенциально опасные места: try видно прямо в коде. Это не занудство, а подсказка мозгу: «здесь может пойти не так». В Swift Evolution даже подчёркивали, что идея await (как явной отметки «тут может быть приостановка») следует прецеденту try (как явной отметки «тут может быть ошибка»).
Если мы ещё и ошибки начнём типизировать по слоям, то код станет не просто «компилируется», а реально управляемым: вы сможете выводить пользователю адекватное сообщение, а внутри приложения не терять причины и контекст.
Модель слоёв и источников ошибок
Представьте, что наша программа LibraryCLI — это маленькая библиотека (в смысле «книги»), где пользователь вводит команды вроде add "Clean Code" или list. Внутри у нас есть доменные правила (что такое книга и какие данные допустимы), есть хранилище (репозиторий), а позже появится сеть (получение данных извне). Ошибки логично привязать к тому месту, где они возникают, иначе мы будем «лечить простуду у розетки».
Удобная модель выглядит так:
flowchart TD
CLI[CLI: ввод/вывод] --> S[Application Service: сценарий]
S --> D[Domain: правила и модели]
S --> R[Repository: доступ к данным]
S --> N[Network: внешние запросы]
D -->|throws| DomainError
R -->|throws| StorageError
N -->|throws| NetworkError
Смысл этой картинки не в том, что у вас обязательно должны быть «папки Domain/Storage/Network». Смысл в договорённости: ошибка должна уметь сказать, из какого мира она пришла.
Доменные ошибки говорят «вход/данные не имеют смысла». Ошибки хранения говорят «не могу сохранить/прочитать». Сетевые ошибки говорят «внешний мир недоступен/ответ странный».
2. DomainError: ошибки смысла и правил
DomainError — это ошибки предметной области. То есть не «файл не открылся» и не «интернет пропал», а именно «то, что вы просите сделать, не имеет смысла по правилам нашей библиотеки». Обычно такие ошибки связаны с инвариантами: заголовок книги не пустой, год издания в разумном диапазоне, идентификатор корректный. Эти ошибки часто возникают даже без репозитория — чисто на уровне создания модели или проверки входных данных.
Начнём с очень простой доменной модели книги. Мы пока делаем её минимальной, чтобы не отвлекаться на детали будущих дней:
import Foundation
struct Book: Equatable {
let id: UUID
let title: String
}
Теперь заведём DomainError. Пусть у нас будет базовое правило: заголовок нельзя сделать пустым после trim:
enum DomainError: Error {
case emptyTitle
}
И добавим доменную функцию валидации (пока прямо в сервисе, но логически это доменное правило). Обратите внимание: мы не печатаем ничего в консоль — домен «молчит», он только сообщает факт ошибки:
import Foundation
func validateTitle(_ raw: String) throws -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { throw DomainError.emptyTitle }
return trimmed
}
Почему это важно? Потому что реакция на DomainError почти всегда «мягкая»: показать пользователю понятное сообщение и дать исправить ввод. Это не «программа сломалась», это «пользователь (или вызывающий код) попросил невозможное по правилам».
4. StorageError: проблемы чтения и записи
StorageError — это ошибки слоя данных. Здесь ключевая мысль: пользователь мог ввести всё идеально, домен может быть счастлив, но данные всё равно могут не сохраниться. Например, хранилище временно недоступно, данные повреждены, или произошёл конфликт (условно: запись уже существует, а политика «перезапись запрещена»).
Сейчас у нас нет файлов и FileManager, поэтому мы сделаем «учебное хранилище» с искусственными сбоями — чтобы увидеть механику ошибок слоя.
Опишем контракт репозитория:
protocol BookRepository {
func save(_ book: Book) throws
func all() throws -> [Book]
}
Теперь сам StorageError:
enum StorageError: Error {
case unavailable(reason: String)
case corruptedData
}
И сделаем репозиторий, который иногда «ломается» (чисто для демонстрации, не для продакшена):
final class InMemoryBookRepository: BookRepository {
private var storage: [UUID: Book] = [:]
var isAvailable: Bool = true
func save(_ book: Book) throws {
if !isAvailable { throw StorageError.unavailable(reason: "DB is locked") }
storage[book.id] = book
}
}
Добавим all() (и опять — без print, просто контракт):
extension InMemoryBookRepository {
func all() throws -> [Book] {
if !isAvailable { throw StorageError.unavailable(reason: "DB is locked") }
return Array(storage.values)
}
}
Чем StorageError отличается от DomainError по реакции? Если StorageError.unavailable, пользователь обычно ничего не может «ввести иначе», чтобы стало лучше. Это техническая проблема.
Для CLI это значит: сообщение другое («не удалось сохранить, попробуйте позже»), и иногда даже код завершения будет другой (но коды завершения мы стандартизируем позже).
5. NetworkError: проблемы внешних запросов
NetworkError — это ошибки общения с внешним миром. Даже если у вас всё хорошо внутри (домен и хранилище в порядке), сеть может вести себя как кот: то приходит, то не приходит, то делает вид, что вас не знает.
Здесь мы берём идею NetworkError, без реализации URLSession и без асинхронности. Нам важно научиться типизировать класс проблем, а не писать настоящий HTTP-клиент (он появится позже отдельным днём).
Чтобы не уходить в детали, мы закрепим четыре интуитивные категории сетевых проблем:
1) транспортная проблема (не подключились, таймаут, DNS),
2) ответ «не похож на HTTP/невалидный»,
3) статус-код «не 200»,
4) данные не декодируются в ожидаемый формат.
В коде это может выглядеть так:
enum NetworkError: Error {
case transport(reason: String)
case invalidResponse
case httpStatus(code: Int)
case decode(reason: String)
}
Важно понять характер: NetworkError почти всегда означает, что источник данных внешний, и мы не контролируем его полностью. Значит, наша стратегия в сервисе обычно «или повторить позже, или пропустить, или показать пользователю понятное сообщение».
Но при этом тип ошибки должен сохранить контекст: статус-код, причину декодирования и т. д. Чтобы не получилось «ERROR: failed», что одинаково информативно, как «мне плохо».
6. AppError: единый тип на границе сервиса
Когда вы разделили ошибки по слоям, возникает новый вопрос: а что возвращать наружу из сервиса? Ведь сервис оркестрирует и домен, и репозиторий, и (в будущем) сеть. Если сервис просто пробрасывает всё как есть, то CLI должен знать слишком много деталей («ага, это StorageError, а это DomainError…»).
Иногда это нормально, но часто хочется дать CLI один понятный тип ошибки уровня сценария.
Сделаем AppError, который оборачивает ошибки нижних слоёв:
enum AppError: Error {
case domain(DomainError)
case storage(StorageError)
case network(NetworkError)
case unknown
}
Смысл AppError не в том, чтобы «смешать обратно всё в одну кашу», а в том, чтобы вернуть единый тип на границу слоя. При этом внутри мы не теряем источник: .storage(...) всё равно хранит StorageError.
7. Маппинг ошибок в LibraryService
Сервис — это место, где удобно делать маппинг (перевод) ошибок, потому что именно он «видит» и домен, и репозиторий, и клиентов внешних систем. CLI не должен гадать, что означает StorageError.unavailable, а домен не должен знать, что такое «HTTP 503». Сервис — переводчик между мирами.
Сделаем LibraryService, который добавляет книгу. Здесь будет три шага: валидация (домен), создание модели (домен), сохранение (репозиторий). И — маппинг ошибок в AppError:
import Foundation
final class LibraryService {
private let repo: any BookRepository
init(repo: any BookRepository) {
self.repo = repo
}
func addBook(title: String) throws -> Book {
do {
let t = try validateTitle(title)
let book = Book(id: UUID(), title: t)
try repo.save(book)
return book
} catch let e as DomainError {
throw AppError.domain(e)
} catch let e as StorageError {
throw AppError.storage(e)
} catch {
throw AppError.unknown
}
}
}
Обратите внимание на маленькую, но важную деталь: мы ловим DomainError и StorageError отдельно, а всё остальное считаем unknown. Это нормальная стратегия «по умолчанию»: неизвестные ошибки не надо притворяться понятными.
Если мы не знаем источник — честно говорим unknown и (в реальном проекте) логируем детали, но логирование будет отдельным днём.
8. Сообщения для пользователя: userMessage
CLI-слой должен печатать сообщения. Но если CLI будет огромным switch на все ошибки всех слоёв, он станет «свалкой знаний» о внутренностях приложения. Поэтому удобно дать ошибке (или AppError) computed property userMessage. Это не магия, а просто способ локализовать правила вывода рядом с типом ошибки.
Сделаем так:
extension AppError {
var userMessage: String {
switch self {
case .domain(.emptyTitle):
return "Название книги не должно быть пустым."
case .storage(.unavailable(let reason)):
return "Хранилище недоступно: \(reason)"
case .storage(.corruptedData):
return "Данные хранилища повреждены."
case .network(.transport(let reason)):
return "Проблема сети: \(reason)"
case .network:
return "Сеть вернула неожиданный ответ."
case .unknown:
return "Неизвестная ошибка."
}
}
}
Да, это довольно «приземлённый» текст. И это хорошо. Пользователь CLI не обязан понимать, что такое decode или «invalid response». Он хочет знать, что делать: исправить ввод, повторить позже или обратиться к администратору (то есть к вам, будущему разработчику).
9. Пример: CLI и разные причины ошибок
Как это выглядит в CLI
Теперь соберём CLI-фрагмент, который вызывает сервис, печатает успех или ошибку. Заметьте, CLI не знает о StorageError и DomainError напрямую — он работает с AppError:
struct CLI {
let service: LibraryService
func runAdd(title: String) {
do {
let book = try service.addBook(title: title)
print("OK: добавили книгу \"\(book.title)\"")
} catch let e as AppError {
print("ERROR:", e.userMessage)
} catch {
print("ERROR: неизвестная ошибка.")
}
}
}
Почему это удобно? Потому что теперь CLI стабильно простой. А если позже мы добавим сеть в сценарий «fetch книгу по id и сохранить», то CLI не надо переписывать — достаточно расширить AppError.userMessage и маппинг в сервисе.
Мини-демо
Важно почувствовать разницу «по слоям» не в теории, а на пальцах. Для этого сделаем небольшой сценарий: сначала всё работает, потом включим «поломку» хранилища, и посмотрим, что поменяется.
Код ниже — просто иллюстрация top-level запуска (как в учебных задачах), без полноценного парсинга команд:
let repo = InMemoryBookRepository()
let service = LibraryService(repo: repo)
let cli = CLI(service: service)
cli.runAdd(title: " Clean Code ") // OK: добавили книгу "Clean Code"
repo.isAvailable = false
cli.runAdd(title: "Refactoring") // ERROR: Хранилище недоступно: DB is locked
Если теперь сделать cli.runAdd(title: " "), то это будет уже доменная ошибка, и сообщение будет другое. Сценарий один и тот же («добавить книгу»), но причины разные — и именно поэтому типизация ошибок по слоям так помогает.
10. Типичные ошибки при проектировании ошибок по слоям
Ошибка №1: «Один enum Error на всё приложение».
Поначалу кажется логичным сделать enum LibraryError: Error { case failed } и радоваться минимализму. На практике это лишает вас главного: по типу ошибки не понятно, виноват ли ввод пользователя, база данных или внешняя система. В результате CLI либо врёт пользователю («попробуйте исправить ввод», хотя проблема в хранилище), либо пишет бессмысленное failed на все случаи.
Ошибка №2: Доменные ошибки начинают описывать инфраструктуру.
Иногда в DomainError внезапно появляется кейс вроде .fileNotFound или .httpStatus(500). Это смешивает смысл и технику: домен должен говорить о правилах предметной области, а не о том, как именно мы храним или получаем данные. Когда домен знает про сеть и диски, архитектура становится хрупкой: вы не можете переиспользовать домен без всего остального.
Ошибка №3: Потеря контекста при маппинге.
Если в сервисе писать catch { throw AppError.unknown } без попытки распознать типы ошибок, вы уничтожаете информацию. Пользователь увидит «неизвестная ошибка», а вы — будете отлаживать по кофейной гуще.
Нормальная практика — сохранять тип слоя (.storage, .domain, .network) и добавлять associated values там, где это реально помогает понять причину.
Ошибка №4: try? на критичных шагах «чтобы не мешало компилироваться».
try? превращает ошибку в nil. Иногда это уместно, но если вы используете его на сохранении в репозиторий или на важной части сценария, вы фактически говорите: «если не сохранилось — ну и ладно».
Потом программа продолжает работу в странном состоянии, а пользователь уверен, что всё хорошо. Лучше пусть сценарий честно завершится с AppError.storage(...), чем будет делать вид, что ничего не случилось.
Ошибка №5: CLI превращается в энциклопедию внутренних ошибок.
Если CLI начинает делать switch на DomainError, StorageError, NetworkError и ещё на десяток деталей, то слой ввода/вывода начинает знать слишком много. Более устойчивый подход — маппить ошибки в сервисе и давать CLI один «верхний» тип (AppError) плюс человекочитаемое сообщение (userMessage). Тогда CLI остаётся тонким, а изменения внутри приложения не требуют переписывать весь ввод/вывод.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ