JavaRush /Курсы /Go SELF /Ошибки на границах — доменные ошибки → формат CLI/HTTP

Ошибки на границах — доменные ошибки → формат CLI/HTTP

Go SELF
28 уровень , 3 лекция
Открыта

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) и показываете стабильное сообщение, а полную ошибку оставляете для логов/диагностики разработчика.

1
Задача
Go SELF, 28 уровень, 3 лекция
Недоступна
Ошибка пропажи
Ошибка пропажи
1
Задача
Go SELF, 28 уровень, 3 лекция
Недоступна
Ошибка валидации
Ошибка валидации
1
Задача
Go SELF, 28 уровень, 3 лекция
Недоступна
Обёртка смысла
Обёртка смысла
1
Задача
Go SELF, 28 уровень, 3 лекция
Недоступна
Ответ без HTTP
Ответ без HTTP
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ