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, адаптер зберігання (поки що в пам’яті) і дуже просту 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 — це не «повідомлення для користувача», а ідентифікатори сенсу: «заголовок порожній», «об’єкт не знайдено». Текст у них теж важливий, але його роль — допомагати під час налагодження та в логах.

Типізована помилка валідації

Коли правил стає більше, нам хочеться не просто сказати «валідація провалена», а ще й додати деталі: яке поле, що саме не так. Це класичний випадок для типізованої помилки.

// domain/validation.go
package domain

type ValidationError struct {
	Fields map[string]string
}

func (e *ValidationError) Error() string {
	return "перевірку не пройдено"
}

Тут Fields — структура даних для коду, а Error() — короткий рядок для людини. Так, він може бути нудним — і це нормально: ми не хочемо будувати інтерфейс на основі 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 прямо підштовхує нас до обгортання через %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
}

Сценарій додавання задачі з обгортанням

// 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("отримання наступного 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("збереження задачі: %w", err)
	}
	return t, nil
}

Тут є невелика, але важлива ідея.

Ми обгортаємо помилки залежностей (NextID, Save) — тому що зверху корисно знати, на якому кроці стався збій.

Ми не зобов’язані обгортати доменні помилки (наприклад, ErrEmptyTitle): вони і так уже на нашому рівні. Але інколи обгортання теж доречне — якщо ви зберігаєте сенс через %w і не перетворюєте текст на контракт.

Обгортання — це частина API-дизайну

І тут з’являється тонкий момент. Обгортання робить помилку «видимою» для коду вище: errors.Is(err, X) зможе знайти X усередині ланцюжка. Це чудово — доки ви випадково не почнете виносити назовні внутрішні помилки адаптера (наприклад, sql.ErrNoRows), тим самим обіцяючи світу: «ми завжди будемо використовувати саме таку БД і саме таку помилку».

В офіційних поясненнях Go щодо помилок це зазвичай формулюють прямолінійно: якщо ви обгортаєте чужу помилку і даєте коду вище можливість спиратися на неї через unwrap, ви робите її частиною API — і потім вам складніше змінювати реалізацію.

Для нашого випадку це означає таке.

Якщо адаптер «не знайшов запис», він має повернути domain.ErrNotFound, а не «якусь помилку мапи» і не помилку бази даних. Тоді domain.ErrNotFound стає тим самим змістовим якорем, який розуміють і CLI, і HTTP.

Якщо адаптер отримав неочікувану технічну помилку (наприклад, файл не відкрився), app-шар може обгорнути її з контекстом ("save task: %w"), а межа може показати користувачеві "internal error", але при цьому в логах або під час друку err для розробника збережеться ланцюжок причин.

І тут проявляється головний баланс: під час налагодження людині корисно бачити все, а користувачеві — лише зрозуміле. У цьому й полягає сенс помилок на межі.

4. Адаптер: переклад технічних проблем у змістові

Адаптери — це місце, де технічна реальність зустрічається з нашим доменом. Навіть якщо сьогодні в нас просте сховище в пам’яті, стиль уже закладається такий самий, як і для серйозніших сховищ: адаптер може повернути 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 стає коротким, а логіка не розповзається по проєкту.

Коротка таблиця відповідностей

Зробімо домовленість. Поки без складної стандартизації — лише розумний мінімум.

Сенс помилки Що показати користувачу Куди друкувати Код виходу
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, "некоректне введення:", ve.Fields) // некоректне введення: map[title:must not be empty]
		return 2
	}
	if errors.Is(err, domain.ErrNotFound) {
		fmt.Fprintln(w, "не знайдено") // не знайдено
		return 1
	}
	fmt.Fprintln(w, "внутрішня помилка") // внутрішня помилка
	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("додано:", t.Title) // додано: купити молоко
}

Тут головне — логіка: main не має замислюватися про типи помилок. Він делегує це межі cli.

6. Межа HTTP: той самий сенс, інша форма

З HTTP — та сама історія, тільки форма інша. Там користувачем часто є не людина, а скрипт або фронтенд-клієнт. Йому потрібна стабільна відповідь: помилка валідації, не знайдено чи внутрішня помилка? Тому HTTP-межа зазвичай повертає структуровану помилку: статус-код і JSON (або принаймні код помилки).

Сьогодні ми не будуємо HTTP-сервер, але можемо (і повинні) підготувати функцію перетворення помилки на HTTP-відповідь. Потім її використовуватимуть в обробниках.

До речі, винесення обробки помилок в одну точку в HTTP — це нормальна практика в Go: замість повторення if err != nil { ... } в кожному обробнику роблять спільний шар, який приймає 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: "некоректний запит", Fields: ve.Fields}
	}
	if errors.Is(err, domain.ErrNotFound) {
		return ErrorResponse{Status: 404, Code: "not_found", Message: "не знайдено"}
	}
	return ErrorResponse{Status: 500, Code: "internal", Message: "внутрішня помилка"}
}

Ми знову використовуємо errors.As і errors.Is. Тобто наш універсальний механізм — це змістовні помилки домену та перевірка по ланцюжку.

Якщо потім в app-шарі ми обгорнули помилку як fmt.Errorf("get task: %w", domain.ErrNotFound), межа все одно розпізнає ErrNotFound, тому що errors.Is проходить по ланцюжку.

Що передавати нагору, а що краще приховати

Дуже хочеться зробити так: «якщо os.Open повернув помилку — просто обгорнемо %w і віддамо нагору». Іноді це правильно. Але іноді таким рішенням ви випадково обіцяєте назовні деталі реалізації.

Сформулюю практично: обгортання — не просто «щоб було красивіше». Обгортання — це спосіб сказати коду вище: «ти можеш на це спиратися». Якщо ви дали коду вище можливість розгорнути чужу помилку, ви фактично підписуєтесь, що вона — частина вашого API, і ви не зможете безболісно змінити технологію.

У нашому todo-застосунку це означає таке.

Якщо адаптер не знайшов запис, він має повернути domain.ErrNotFound, а не якусь map-помилку і не помилку бази даних. Тоді domain.ErrNotFound стає тим самим змістовим якорем, який розуміють і CLI, і HTTP.

Якщо адаптер отримав неочікувану технічну помилку (наприклад, файл не відкрився), app-шар може обгорнути її з контекстом ("save task: %w"), а межа може показати користувачеві "internal error", але в логах або під час друку err для розробника збережеться ланцюжок причин.

І тут проявляється головний баланс: під час налагодження людині корисно бачити все, а користувачеві — лише зрозуміле. Це і є сенс помилок на межі.

7. Типові помилки

Помилка №1: порівняння помилок за рядком (err.Error() == "not found").
Це здається швидким рішенням, але воно перетворює текст на контракт. А текст майже завжди змінюється: ви додасте контекст, виправите формулювання, локалізуєте повідомлення — і раптом логіка розгалуження зламається. Натомість використовуйте errors.Is для sentinel-помилок і errors.As для типізованих помилок, щоб розгалужуватися за сенсом.

Помилка №2: обгортання без %w там, де сенс важливий.
Якщо ви пишете fmt.Errorf("save task: %v", err), то на перший погляд усе виглядає нормально, але для програми ланцюжок причин губиться: errors.Is/errors.As уже не зможуть знайти початкову доменну помилку. Якщо ви розраховуєте на розпізнавання сенсу на межі, використовуйте %w.

Помилка №3: витік деталей реалізації через обгортання.
Іноді розробник робить «як у підручнику»: усюди %w, усе «правильно». А потім з’ясовується, що верхній шар почав перевіряти errors.Is(err, sql.ErrNoRows) або іншу помилку конкретної бібліотеки. Це прив’язує ваш код до конкретної технології й робить рефакторинг болісним. Там, де технологія — внутрішня деталь, краще «перекладати» помилку в доменну (наприклад, domain.ErrNotFound) і назовні випускати вже її.

Помилка №4: домен друкує помилки або вибирає exit code.
Це одна з найруйнівніших звичок: «я просто fmt.Println тут, щоб було видно». За кілька тижнів домен починає залежати від fmt, сценарії — від os.Exit, а тестувати все стає сумно. Тримайте залізне правило: домен і app повертають error, межа вирішує, що з ним робити і як це виглядатиме для користувача.

Помилка №5: межа показує користувачеві err.Error() за будь-якої помилки.
У помилці може бути забагато зайвого: внутрішні шляхи, технічні деталі, інколи навіть шматки конфігурації. Це не допомагає користувачеві, а інколи ще й шкодить. Нормальний стиль: на межі ви розпізнаєте сенс (validation/notfound/internal) і показуєте стабільне повідомлення, а повну помилку лишаєте для логів та діагностики розробника.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ