1. Граница приложения: где ошибка становится «человеческой»
Если вы когда-нибудь видели программу, которая гордо пишет пользователю panic: runtime error: invalid memory address, то вы понимаете: «пользовательский интерфейс ошибок» может быть… очень честным. А нам нужна честность другого типа: внутри кода мы хотим максимально точную причину, а снаружи — понятное и стабильное сообщение. Вот это место, где «внутренний мир» встречается с «наружным», и называется границей.
Под границей я буду понимать любой слой, который общается с внешним миром: CLI (командная строка), HTTP API, иногда UI, иногда импорт/экспорт файлов. Внутренние слои (domain/app) не должны знать, как именно мы общаемся с пользователем. Иначе домен начнёт печатать в консоль, а сценарии начнут решать HTTP-статусы — и архитектура превратится в салат.
Представим наше учебное приложение: маленький todo-менеджер. Внутри у нас доменная модель Task, сценарии Add/Get, адаптер хранения (пока in-memory), и очень простой CLI-«вход». Сегодня мы научимся делать так, чтобы ошибки проходили через слои аккуратно: смысл сохранялся, а форма менялась только на границе.
2. Доменные ошибки: смысл важнее текста
Когда мы говорим «ошибка домена», мы обычно имеем в виду не «что-то сломалось», а «произошло запрещённое состояние с точки зрения предметной области». Например, у задачи не может быть пустого заголовка. Это не проблема консоли или HTTP — это правило нашего todo-мира.
Важно держать в голове одну идею: ветвление по ошибкам должно происходить по смыслу, а не по строке. Строка — это то, что читают люди. А код должен ориентироваться на различимые значения: sentinel errors или типизированные ошибки и проверки через errors.Is/errors.As. В Go это напрямую поддержано через errors.Is и errors.As, которые умеют “видеть” ошибку даже внутри цепочки обёрток (error chain).
Sentinel-ошибки в domain
Начнём с простого: несколько «маркерных» ошибок в домене.
// domain/errors.go
package domain
import "errors"
var ErrEmptyTitle = errors.New("empty title")
var ErrNotFound = errors.New("not found")
ErrEmptyTitle и ErrNotFound — это не «сообщения для пользователя», а идентификаторы смысла: «заголовок пустой», «объект не найден». Текст у них тоже важен, но его задача — быть полезным при отладке и в логах.
Типизированная ошибка валидации
Когда правил становится больше, нам хочется не просто сказать «валидация провалена», а ещё и приложить детали: какое поле, что не так. Это классический случай для typed error.
// domain/validation.go
package domain
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Здесь Fields — структура данных для кода, а Error() — короткая строка для людей. Да, она очень скучная — это нормально: мы не хотим строить UI из Error().
И да, это тот случай, когда потом на границе (CLI/HTTP) мы будем доставать Fields через errors.As.
Инварианты: создаём сущность или возвращаем ошибку
Домен — это место, где мы удерживаем инварианты. То есть мы либо создаём корректную Task, либо честно говорим «нельзя».
В нашем todo-приложении пусть Task создаётся через конструктор:
// domain/task.go
package domain
type Task struct {
ID int
Title string
Done bool
}
func NewTask(id int, title string) (Task, error) {
if title == "" {
return Task{}, ErrEmptyTitle
}
return Task{ID: id, Title: title, Done: false}, nil
}
Обратите внимание на важный стиль: мы возвращаем zero value (Task{}) вместе с ошибкой. Никаких «полусобранных» сущностей. Это делает код выше по слоям проще: если err != nil, объектом пользоваться нельзя — точка.
Если бы правил было больше, мы могли бы возвращать *ValidationError с полями:
// domain/task.go (вариант, если хотим поля)
package domain
func NewTask(id int, title string) (Task, error) {
fields := make(map[string]string)
if title == "" {
fields["title"] = "must not be empty"
}
if len(fields) > 0 {
return Task{}, &ValidationError{Fields: fields}
}
return Task{ID: id, Title: title, Done: false}, nil
}
Этот вариант особенно удобен для будущего HTTP API, но уже сейчас полезен и для CLI: можно показать пользователю, что именно он ввёл не так.
3. Ошибки через слои: контекст добавляем, смысл сохраняем
Теперь слой сценариев (app) — тот, который «оркестрирует» шаги: получить ID, создать задачу, сохранить. Здесь очень легко сделать две типичные ошибки: либо потерять контекст («где именно упало?»), либо потерять смысл («почему упало?»). Наша цель — сохранить и то, и другое.
Go прямо подталкивает нас к wrapping через %w: так ошибка остаётся “распаковываемой” для errors.Is/errors.As.
Интерфейс зависимости объявляет app, а не адаптер
Сценарию важно не знать, какое хранилище у нас (память, файл, база), поэтому он принимает интерфейс.
// app/store.go
package app
import (
"context"
"example.com/todoapp/domain"
)
type TaskStore interface {
NextID(ctx context.Context) (int, error)
Save(ctx context.Context, t domain.Task) error
}
Сценарий добавления задачи с wrapping
// app/add.go
package app
import (
"context"
"fmt"
"example.com/todoapp/domain"
)
func AddTask(ctx context.Context, store TaskStore, title string) (domain.Task, error) {
id, err := store.NextID(ctx)
if err != nil {
return domain.Task{}, fmt.Errorf("next id: %w", err)
}
t, err := domain.NewTask(id, title)
if err != nil {
return domain.Task{}, err
}
if err := store.Save(ctx, t); err != nil {
return domain.Task{}, fmt.Errorf("save task: %w", err)
}
return t, nil
}
Здесь есть маленькая философия.
Мы wrap’аем ошибки зависимостей (NextID, Save) — потому что сверху полезно знать, на каком шаге упало.
Мы не обязаны wrap’ать доменные ошибки (например ErrEmptyTitle): они и так уже «нашего уровня». Но иногда wrap тоже ок — если вы сохраняете смысл через %w и не превращаете текст в контракт.
Wrapping — это часть API-решения
И тут появляется тонкий момент. Wrapping делает ошибку “видимой” для кода выше: errors.Is(err, X) сможет найти X внутри цепочки. Это круто… пока вы случайно не начали протаскивать наружу внутренние ошибки адаптера (например, sql.ErrNoRows), тем самым обещая миру: «мы всегда будем использовать именно такую БД и именно такую ошибку».
В официальных разборах ошибок в Go формулировка обычно прямолинейная: если вы wrap’аете чужую ошибку и даёте коду выше возможность опираться на неё через unwrap, вы делаете её частью API — и потом вам сложнее менять реализацию.
Для нашего дня это означает: доменные ошибки можно делать частью контракта, а ошибки конкретных технологий — лучше «переводить» в доменные (или в ошибки слоя), чтобы наружу не торчали детали.
4. Адаптер: перевод технических проблем в смысловые
Адаптеры — это место, где техническая реальность встречается с нашим доменом. Даже если сегодня у нас простое in-memory хранилище, стиль уже закладывается такой же, как для более серьёзных хранилищ: адаптер может вернуть domain.ErrNotFound, если записи нет.
Сделаем минимальный memstore:
// adapters/memstore/store.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
type Store struct {
next int
data map[int]domain.Task
}
func New() *Store {
return &Store{next: 1, data: make(map[int]domain.Task)}
}
А теперь методы. Старайтесь держать их простыми, без «умничанья».
// adapters/memstore/save.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
func (s *Store) NextID(ctx context.Context) (int, error) {
id := s.next
s.next++
return id, nil
}
func (s *Store) Save(ctx context.Context, t domain.Task) error {
s.data[t.ID] = t
return nil
}
А вот пример чтения с “not found”:
// adapters/memstore/get.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
func (s *Store) ByID(ctx context.Context, id int) (domain.Task, error) {
t, ok := s.data[id]
if !ok {
return domain.Task{}, domain.ErrNotFound
}
return t, nil
}
Здесь адаптер не говорит «map key missing» (потому что это вообще не смысл домена). Он говорит «не найдено» на языке домена.
5. Граница CLI: сообщение и код возврата
CLI — это особая граница. Она любит конкретику: что написать пользователю, куда писать (stdout или stderr), и какой exit code вернуть. При этом CLI не должен разбирать «внутренности» приложения по строкам ошибок — только по смыслу через errors.Is/errors.As.
Один из самых полезных приёмов в Go (и вообще в жизни) — вынести форматирование ошибок в отдельную функцию: WriteError(...) или RenderError(...). Так main становится коротким, а логика не размазывается по проекту.
Мини-таблица соответствий
Сделаем договор. Пока без сложной стандартизации, просто разумный минимум.
| Смысл ошибки | Что показать пользователю | Куда печатать | Exit code |
|---|---|---|---|
| validation | “Некорректный ввод: …” | stderr | 2 |
| not found | “Не найдено: …” | stderr | 1 |
| прочее | “Внутренняя ошибка” | stderr | 1 |
Да, эта таблица ещё будет эволюционировать, но сама идея важнее: граница решает форму, внутренние слои — смысл.
Рендер ошибки для CLI через errors.Is/As
// boundary/cli/errors.go
package cli
import (
"errors"
"fmt"
"io"
"example.com/todoapp/domain"
)
func WriteError(w io.Writer, err error) int {
var ve *domain.ValidationError
if errors.As(err, &ve) {
fmt.Fprintln(w, "invalid input:", ve.Fields) // invalid input: map[title:must not be empty]
return 2
}
if errors.Is(err, domain.ErrNotFound) {
fmt.Fprintln(w, "not found") // not found
return 1
}
fmt.Fprintln(w, "internal error") // internal error
return 1
}
Обратите внимание на errors.As(err, &ve). Тут мы передаём адрес переменной, чтобы функция могла «записать найденную ошибку» внутрь неё — это стандартный контракт errors.As.
И ещё одна деталь: мы не выводим err.Error() пользователю в «прочих» случаях. Почему? Потому что в err может быть всё: детали хранилища, пути файлов, внутренние подсказки для разработчика. Пользователь от этого обычно не счастлив, а иногда это ещё и небезопасно.
Пример main.go: минимальный каркас
Сделаем супер-простой CLI без flag (его будем разбирать отдельно в другой части курса). Пусть команда будет такая: todoadd <title>.
// cmd/todo/main.go
package main
import (
"context"
"fmt"
"os"
"example.com/todoapp/adapters/memstore"
"example.com/todoapp/app"
"example.com/todoapp/boundary/cli"
)
func main() {
store := memstore.New()
title := ""
if len(os.Args) >= 2 {
title = os.Args[1]
}
t, err := app.AddTask(context.Background(), store, title)
if err != nil {
code := cli.WriteError(os.Stderr, err)
os.Exit(code)
}
fmt.Println("added:", t.Title) // added: buy milk
}
Здесь главное — структура мысли: main не должен «думать» про типы ошибок. Он делегирует это границе cli.
6. Граница HTTP: тот же смысл, другая форма
С HTTP та же история, только форма другая. Пользователь там часто не человек, а скрипт или фронтенд-клиент. Ему нужно стабильно понимать: это ошибка валидации? не найдено? внутренняя? Поэтому HTTP-граница обычно возвращает «структурированную» ошибку: статус-код + JSON (или хотя бы код ошибки).
Сегодня мы не строим HTTP сервер, но мы можем (и должны) подготовить функцию преобразования ошибки в HTTP-ответ. Потом она будет использоваться в handler’ах.
Кстати, вынос обработки ошибок в одну точку для HTTP — это нормальная практика в Go: вместо повторения if err != nil { ... } в каждом handler’е делают общий слой, который принимает error и решает, что отправить наружу.
Структура ответа об ошибке
// boundary/httpx/errors.go
package httpx
type ErrorResponse struct {
Status int
Code string
Message string
Fields map[string]string
}
Это не net/http и не реальный JSON — это просто структура, которую легко потом закодировать. Важная мысль: Message — для внешнего мира, Code — для машин, Fields — для валидации.
Маппинг ошибок в HTTP-ответ
// boundary/httpx/map.go
package httpx
import (
"errors"
"example.com/todoapp/domain"
)
func ToErrorResponse(err error) ErrorResponse {
var ve *domain.ValidationError
if errors.As(err, &ve) {
return ErrorResponse{Status: 400, Code: "validation", Message: "bad request", Fields: ve.Fields}
}
if errors.Is(err, domain.ErrNotFound) {
return ErrorResponse{Status: 404, Code: "not_found", Message: "not found"}
}
return ErrorResponse{Status: 500, Code: "internal", Message: "internal error"}
}
Мы снова используем errors.As и errors.Is. То есть наш «универсальный двигатель» — смысловые ошибки домена и проверка по цепочке.
Если потом в app-слое мы обернули ошибку как fmt.Errorf("get task: %w", domain.ErrNotFound), граница всё равно распознает ErrNotFound, потому что errors.Is проходит по цепочке.
Что протаскивать наверх, а что лучше скрыть
Очень хочется сделать так: «если os.Open вернул ошибку — просто обернём %w и отдадим наверх». Иногда это правильно. Но иногда вы этим решением случайно обещаете наружу детали реализации.
Формулирую практично: wrapping — не просто «чтобы было красивее», wrapping — это способ сказать коду выше: «ты можешь на это опираться». Если вы дали возможность unwrap’нуть чужую ошибку, вы как бы подписались, что она — часть вашего API, и вы не можете безболезненно поменять технологию.
В нашем todo-приложении это означает следующее.
Если адаптер «не нашёл запись», он должен вернуть domain.ErrNotFound, а не «какую-то ошибку map’ы» и не «ошибку базы данных». Тогда domain.ErrNotFound становится тем самым смысловым якорем, который понимают и CLI, и HTTP.
Если адаптер получил неожиданную техническую ошибку (например, «файл не открылся»), app-слой может wrap’нуть её с контекстом ("save task: %w"), а граница может показать пользователю "internal error", но при этом лог (или печать err для разработчика) будет содержать цепочку причин.
И вот тут всплывает главный баланс: человеку при отладке полезно видеть всё, а пользователю — только понятное. Это и есть смысл «ошибок на границах».
7. Типичные ошибки
Ошибка №1: сравнение ошибок по строке (err.Error() == "not found").
Это кажется быстрым решением, но оно превращает текст в контракт, а текст почти всегда меняется: вы добавите контекст, поправите формулировку, локализуете сообщение — и внезапно логика ветвления сломается. Вместо этого используйте errors.Is для sentinel-ошибок и errors.As для типизированных ошибок, чтобы ветвиться по смыслу.
Ошибка №2: wrapping без %w там, где смысл важен.
Если вы пишете fmt.Errorf("save task: %v", err), то на вид человеку всё ок, но для программы цепочка причин теряется: errors.Is/errors.As уже не смогут найти исходную доменную ошибку. Если вы рассчитываете на распознавание смысла на границе — используйте %w.
Ошибка №3: утечка деталей реализации через wrapping.
Иногда разработчик делает «как по учебнику»: везде %w, всё «правильно». А потом выясняется, что верхний слой начал проверять errors.Is(err, sql.ErrNoRows) или другую ошибку конкретной библиотеки. Это связывает ваш код с конкретной технологией и делает рефакторинг болезненным. Там, где технология — внутренняя деталь, лучше «переводить» ошибку в доменную (например, domain.ErrNotFound) и наружу выпускать уже её.
Ошибка №4: домен печатает ошибки или выбирает exit code.
Это одна из самых разрушительных привычек: «я просто fmt.Println здесь, чтобы было видно». Через пару недель домен начинает зависеть от fmt, сценарии — от os.Exit, а тестировать всё становится грустно. Держите железное правило: домен и app возвращают error, граница решает, что с ним делать и как это выглядит для пользователя.
Ошибка №5: граница показывает пользователю err.Error() для любой ошибки.
Внутри ошибки может быть слишком много лишнего: внутренние пути, технические детали, иногда даже куски конфигурации. Пользователю это не помогает, а иногда вредно. Нормальный стиль: на границе вы распознаёте смысл (validation/notfound/internal) и показываете стабильное сообщение, а полную ошибку оставляете для логов/диагностики разработчика.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ