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) і показуєте стабільне повідомлення, а повну помилку лишаєте для логів та діагностики розробника.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ