JavaRush /Курсы /Swift SELF /Ошибки по слоям — DomainError vs StorageError

Ошибки по слоям — DomainError vs StorageError

Swift SELF
47 уровень , 0 лекция
Открыта

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 остаётся тонким, а изменения внутри приложения не требуют переписывать весь ввод/вывод.

1
Задача
Swift SELF, 47 уровень, 0 лекция
Недоступна
Чистый заголовок
Чистый заголовок
1
Задача
Swift SELF, 47 уровень, 0 лекция
Недоступна
Закрытое хранилище
Закрытое хранилище
1
Задача
Swift SELF, 47 уровень, 0 лекция
Недоступна
Ошибки сервиса
Ошибки сервиса
1
Задача
Swift SELF, 47 уровень, 0 лекция
Недоступна
Единая ошибка
Единая ошибка
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ