1. Чому один Error для всього застосунку — це шлях до смутку
Коли ви тільки починаєте знайомитися з throws, дуже хочеться зробити так: «Ну добре, буде enum AppError: Error { case somethingWentWrong } — і поїхали». На перший погляд це схоже на мінімалізм, але на практиці перетворюється на гру «вгадай за стек-трейсом», чому застосунок не зробив того, що ви хотіли. А стек-трейс, як на зло, показує вам саме те місце, де ви дізналися про проблему, і рідко — те, де ви її створили.
Помилки в застосунку мають важливу властивість: вони виникають у різних частинах системи з різних причин. Якщо все змішати в одну купу, вам буде складно зробити дві речі: по-перше, правильно відреагувати (наприклад, на помилку введення показати підказку, а на помилку збереження — запропонувати повторити спробу пізніше); по-друге, зрозуміти, вам як розробнику, де саме шукати баг. Тому ми вводимо модель шарів відповідальності: помилки діляться за тим, у якому шарі вони виникли.
Ментальна модель: чотири шари помилок
Шари — це не обов’язкова архітектура і не закон природи. Це зручна карта, якою ви користуєтеся, коли маєте ланцюжок дій. Для навчального CLI-застосунку ця карта зазвичай виглядає так: parse → domain → storage → network. Навіть якщо у вас поки немає мережі й файлів, модель усе одно корисна, бо вона привчає вас мислити: «ця помилка про введення», «ця — про бізнес-правила», «ця — про збереження», «ця — про зовнішній світ».
Нижче — коротка таблиця, щоб закріпити поняття:
| Шар | Про що цей шар | Головне питання | Приклад помилки |
|---|---|---|---|
| parse | Розбір введення: команда, аргументи, формат | «Що мав на увазі користувач?» | невідома команда, бракує аргументів |
| domain | Правила предметної області | «Чи можна так за правилами?» | рік книжки поза діапазоном, порожня назва |
| storage | Читання/запис даних | «Чи можемо ми зберегти або прочитати?» | не вдалося зберегти файл, запис пошкоджено |
| network | Зовнішні системи | «Чи можемо ми поговорити із сервісом?» | немає мережі, некоректний HTTP-статус, тайм-аут |
Ключова ідея: шар повинен мати свою лексику помилок. Тоді навіть без логів ви розумієте: «ага, зараз проблема саме в парсингу», а не «щось пішло не так, і ми через це сумуємо».
2. Де ці шари живуть у LibraryCLI
Уявімо, що ми й далі розвиваємо наш CLI-застосунок LibraryCLI (умовну «домашню бібліотеку»), у якому є команди на кшталт add, list, remove. Користувач вводить рядок, ми його розбираємо, перетворюємо на команду, застосовуємо до доменної моделі, а потім — у майбутньому — зберігаємо зміни. Навіть якщо поки ми тримаємо дані лише в пам’яті, структура ланцюжка вже схожа на справжню.
Схематично це виглядає так:
flowchart TD
A["Введення користувача (String)"] --> B["Розбір (CommandParser)"]
B -->|Команда| C["Домен (LibraryService/Rules)"]
C -->|Зміни| D["Сховище (Repository)"]
C -->|Запити| D
C --> E["Мережа (за потреби)"]
B -->|ParseError| X["Помилка розбору"]
C -->|DomainError| Y["Помилка домену"]
D -->|StorageError| Z["Помилка сховища"]
E -->|NetworkError| W["Помилка мережі"]
Важливо: шар parse не повинен знати про правила домену (наприклад, «рік книжки в діапазоні від 1450...2100»). Його завдання — зрозуміти, що користувач написав add "Dune" 1965, і перетворити це на структуровану команду. А шар domain уже вирішує, чи припустимі такі значення.
4. Помилки за шарами: ParseError, DomainError, StorageError, NetworkError
ParseError: проблеми формату та «людина ввела нісенітницю»
Парсинг — це місце, де ви вперше зустрічаєтеся з реальним світом в особі користувача. Він може написати що завгодно: порожній рядок, команду з опискою, забути аргументи, переплутати порядок. Помилка парсингу — це не внутрішня проблема застосунку. Це нормальний сценарій: людині треба підказати, що саме не так із введенням.
Почнімо з простого enum:
import Foundation
enum ParseError: Error {
case emptyInput
case unknownCommand(String)
case missingArgument(name: String)
case invalidNumber(String)
}
Зверніть увагу: тут ми не пишемо «повідомлення для користувача», не форматуємо текст і не вирішуємо, як це показувати. Ми просто даємо точні причини, які допоможуть верхньому рівню коректно відреагувати.
Тепер зробімо мікрофункцію, яка розбирає ціле число (наприклад, рік):
import Foundation
func parseInt(_ text: String) throws -> Int {
guard let value = Int(text) else {
throw ParseError.invalidNumber(text)
}
return value
}
І парсер команди add на рівні ідеї — спрощено, без лапок і складних правил токенізації. Їх ми розберемо окремо в темі парсингу CLI, а тут важливий саме шар помилок:
import Foundation
struct AddBookCommand {
let title: String
let year: Int
}
func parseAdd(tokens: [String]) throws -> AddBookCommand {
guard tokens.count >= 3 else { throw ParseError.missingArgument(name: "title/year") }
let title = tokens[1]
let year = try parseInt(tokens[2])
return AddBookCommand(title: title, year: year)
}
Так, це поки що наївний парсер, але він чудово показує ідею: parse-помилки — про форму введення. Якщо число не розпарсилося — це ParseError.invalidNumber, а не DomainError і не StorageError.
DomainError: помилки правил предметної області
Коли ви доходите до домену, форма даних уже зрозуміла. Команду розпізнано, аргументи на місці, числа розпарсилися. Тепер починається інша логіка: «за правилами нашого світу так можна чи не можна».
Для LibraryCLI давайте заведемо сутність книжки та правила. Поки що ми не заглиблюємося в майбутні теми, тому обмежимося мінімумом:
import Foundation
struct Book {
let title: String
let year: Int
}
Так можуть виглядати помилки домену:
import Foundation
enum DomainError: Error {
case emptyTitle
case yearOutOfRange(Int)
}
І валідатор — можна як функцію, можна як метод, тут це не принципово:
import Foundation
func validateBook(title: String, year: Int) throws {
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw DomainError.emptyTitle
}
guard (1450...2100).contains(year) else {
throw DomainError.yearOutOfRange(year)
}
}
Зверніть увагу на важливу деталь: 1450...2100 — це правило домену. Його взагалі не повинен знати парсер. Його завдання — лише зрозуміти «рік = 3000» як число. А домен уже скаже: «ні, у нас так не заведено, ми не приймаємо книжки з майбутнього (поки що)».
StorageError: помилки зберігання
Навіть якщо ви пишете ідеальний домен і бездоганно парсите команди, настає момент, коли дані треба десь зберігати. У навчальному застосунку спочатку це буде пам’ять (Array), а потім — файли, JSON тощо. Помилки збереження з’являються не тому, що користувач щось не так ввів, і не тому, що порушив правила. Вони виникають тому, що середовище виконання не дало вам зробити I/O або дані виявилися пошкодженими.
Поки у нас немає файлів у цій темі, ми можемо змоделювати storage-шар як протокол репозиторію та просту реалізацію, яка час від часу ламається — суто для того, щоб побачити, як працюють типи помилок.
import Foundation
enum StorageError: Error {
case cannotSave
case cannotLoad
}
Найпростіший репозиторій:
import Foundation
final class InMemoryBookRepository {
private var books: [Book] = []
func add(_ book: Book) {
books.append(book)
}
func listAll() -> [Book] {
books
}
}
А тепер уявімо операцію збереження, яка може кинути помилку (у реальному житті це буде файл):
import Foundation
func saveSnapshot() throws {
let ok = Bool.random()
guard ok else { throw StorageError.cannotSave }
}
Головна думка: StorageError живе на межі взаємодії зі сховищем. Цей тип не повинен описувати «порожній title», бо це не проблема зберігання, а проблема домену.
NetworkError: помилки зовнішнього світу
Мережа — найчесніший шар. Вона одразу каже: «усе може зламатися, і це не ваша вина». Навіть якщо ви сьогодні не пишете мережеву частину, корисно тримати окремий тип помилок мережі, бо це дисциплінує: мережеві збої не можна лікувати тими самими методами, що й помилки парсингу або домену.
Поки без URLSession (це буде в іншій темі), ми просто зафіксуємо ідею:
import Foundation
enum NetworkError: Error {
case offline
case timeout
case badResponse
}
І «фейковий» запит:
import Foundation
func fetchSomething() throws {
throw NetworkError.offline
}
Чому важливо не змішувати network і storage? Бо реакція часто різна. Помилка збереження зазвичай означає «спробуйте пізніше, перевірте права або шлях», а помилка мережі — «перевірте інтернет і повторіть запит». Коли ви розділяєте типи, вам легше зробити правильний UX, навіть у CLI.
5. Як пов’язувати шари: правила, трансляція та точка обробки
Правила шарів відповідальності
Шари помилок — це не просто чотири назви. Це набір правил, які допомагають не перетворити проєкт на хаос із catch { print("ой") }. Найважливіше правило звучить майже нудно: помилка має бути рідною для свого шару. Якщо на рівні парсингу ви починаєте перевіряти доменні правила, ви отримуєте код, де логіка розмазана по всьому застосунку.
Друге правило теж просте, але часто ламає звички новачків: нижні шари не зобов’язані бути зручними для користувача. Нижній шар повинен бути точним. Краса зазвичай народжується вище — там, де ви знаєте контекст сценарію і можете вирішити: «це помилка введення, покажемо help» або «це помилка збереження, дамо пораду зберегти в іншу теку».
Третє правило — про збереження сенсу: якщо ви піднімаєте помилку вгору, не перетворюйте все на unknown. Іноді вам справді не потрібні деталі, але категорії мають зберігатися: parse vs domain vs storage vs network — це вже велика користь.
Трансляція помилок між шарами
Коли у вас кілька типів помилок, виникає питання: «а що повертати нагору?» Часто верхній рівень (наприклад, runCLI()) не хоче знати всі деталі нижніх рівнів, але хоче розуміти категорію. Для цього створюють верхньорівневу помилку застосунку, яка обгортає помилки шарів.
Це і називається трансляцією: низькорівнева причина → верхньорівневий словник помилок. Важливо: трансляція — це не «сховати проблему», а «упакувати її так, щоб верхній шар міг правильно реагувати».
Зробімо AppError, який зберігає шар:
import Foundation
enum AppError: Error {
case parse(ParseError)
case domain(DomainError)
case storage(StorageError)
case network(NetworkError)
case unexpected(Error)
}
Тепер напишемо функцію, яка проходить ланцюжок: parse → domain → storage, і переводить помилки. Тут важливо: ми не використовуємо try?, бо нам потрібна причина, щоб правильно її транслювати.
import Foundation
func addBookFlow(input: String, repo: InMemoryBookRepository) throws {
do {
let tokens = input.split(separator: " ").map(String.init)
guard let cmd = tokens.first else { throw ParseError.emptyInput }
guard cmd == "add" else { throw ParseError.unknownCommand(cmd) }
let add = try parseAdd(tokens: tokens)
try validateBook(title: add.title, year: add.year)
repo.add(Book(title: add.title, year: add.year))
try saveSnapshot()
} catch let e as ParseError {
throw AppError.parse(e)
} catch let e as DomainError {
throw AppError.domain(e)
} catch let e as StorageError {
throw AppError.storage(e)
} catch {
throw AppError.unexpected(error)
}
}
Зверніть увагу, що код виглядає довшим, ніж простий try. Але це як ремінь безпеки: спочатку він здається зайвим, а потім ви один раз ловите дивний баг і починаєте любити цей ремінь усім серцем.
Де ловити помилки
У CLI-застосунку майже завжди є точка, де ви остаточно вирішуєте: «що виводити користувачу і з яким кодом завершуватися». У межах цієї лекції ми не вводимо коди завершення й політику повідомлень (це тема сусіднього заняття), але принцип усе одно корисний: ловити помилки краще ближче до точки, де ви справді можете щось зробити.
Зробімо мініранер, який викликає flow і друкує, що сталося. Тут ми поки друкуємо технічно, щоб побачити категорії:
import Foundation
let repo = InMemoryBookRepository()
do {
try addBookFlow(input: "add Dune 1965", repo: repo)
print("OK: книжку додано") // OK: книжку додано
} catch let e as AppError {
print("ПОМИЛКА ЗАСТОСУНКУ:", e) // ПОМИЛКА ЗАСТОСУНКУ: ...
} catch {
print("НЕСПОДІВАНА ПОМИЛКА:", error)
}
Так, print(e) поки виглядає «сирувато». Але це нормально: сьогодні наша мета — структура, а не фінальний текст.
Мінісценарії: як введення ламається на різних шарах
Дуже корисно побачити, що одна й та сама команда може «впасти» в різних місцях. Уявіть, що користувач вводить:
- "" (порожньо) — це parse: немає команди.
- "add 1965" (пробіли замість назви) — формально парсер може щось витягнути, але домен скаже «порожній title».
- "add Dune 9999" — parse успішний, domain нарікає на діапазон.
- "add Dune 1965" — усе чудово, але storage «випадково» не зберіг.
Перевірімо два випадки короткими викликами:
import Foundation
do {
try addBookFlow(input: "add Dune 9999", repo: repo)
} catch {
print(error) // AppError.domain(...)
}
import Foundation
do {
try addBookFlow(input: "add Dune 1965", repo: repo)
} catch {
print(error) // Може бути AppError.storage(...) через saveSnapshot()
}
Сенс саме в тому, що ви за типом помилки уже розумієте, куди дивитися. Це економить час і мозок. Мозок, до речі, ще знадобиться, бо програмування зазвичай не відпускає його додому в пʼятницю ввечері.
6. Типові помилки
Помилка №1: змішувати parse і domain «бо так простіше».
Частий сценарій: у парсері команди ви одразу перевіряєте діапазони, унікальність, «чи можна видаляти цю книжку» тощо. У результаті парсер стає монстром, а домен перетворюється на порожню оболонку. Краще терпляче розділяти: parse відповідає за форму, domain — за правила.
Помилка №2: робити один catch { throw AppError.unexpected(error) } і вважати, що «я все обробив».
Такий код компілюється і навіть працює, але ви втрачаєте весь сенс шарів. Користувач отримає однакову поведінку і на описку в команді, і на проблему зі збереженням. А ви як розробник будете довго гадати, де саме зламалося.
Помилка №3: «ковтати» помилки через try? до того, як ви їх перевели.
Якщо ви зробили let x = try? something() посеред ланцюжка, ви перетворили «причину» на nil. Після цього переводити вже нічого: ви вже не знаєте, чи це був ParseError, StorageError або щось інше. try? — корисний інструмент, але він має з’являтися лише там, де вам справді не важлива причина. Для трансляції причина важлива майже завжди.
Помилка №4: зберігати в нижніх шарах готові рядки для користувача.
Якщо DomainError повертає «Будь ласка, введіть рік від 1450 до 2100 (дякую)», здається, що ви зробили зручно. Але за тиждень ви захочете виводити інші тексти, додати підказку help, змінити мову повідомлень або формат. Тому в нижніх шарах тримаємо дані помилки, а текст формуємо вище (у межах цієї теми це окреме питання).
Помилка №5: робити «помилку-заглушку» замість конкретних кейсів.
enum StorageError { case failed } — це як записка «я зайнятий» без підпису й часу: формально щось сказали, але нічого корисного. Навіть якщо ви поки не знаєте всіх кейсів, почніть хоча б із двох-трьох змістовних: cannotLoad, cannotSave, corruptedData. Конкретика — це економія часу на налагодженні.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ