Опирается на правила:
R-ERR-RESULT-1…R-ERR-RESULT-2иR-ERR-RESULT-X1из Error Handling Style Guide → раздел 6. Result-types vs exceptions.
Важно знать
(T, error)— норма языка, не аналогResult-монады. Возвращать(T, error)везде — правильно, не «избыточно».panic— только для невосстановимых программистских ошибок: nil-pointer в инварианте конструктора, write в nil-map, индекс за границей в месте, которое должен поймать unit-тест.panicне для бизнес-правил. Бизнес-правило нарушено →return &SomeDomainError{...}.recover()— только вRecoverer-middleware на edge. В usecase, домене, адаптере — нольrecover().panic/recoverкак control-flow между слоями — запрещён. Имитация try/catch черезpanic/recoverломает читаемость и контракт слоёв.- Функциональный
Result-тип (type Result[T any] struct) применим точечно в парсерах/calc-engine — там, где ошибка семантически часть результата.
Раздел «Result vs exceptions» в языке без исключений читается инверсно. В Go возврат (T, error) — идиома языка, не обходной маневр. Вопрос не «использовать ли (T, error)», а «когда panic, а когда дополнительный Result-тип». Раскрытие правил R-ERR-RESULT-* ниже.
R-ERR-RESULT-1 — (T, error) как стандарт
Возвращать (T, error) — не избыточно, это то, что ожидает любой Go-разработчик и любой инструмент:
errcheckлинтер статически проверяет «не проигнорирована ли ошибка».errors.As/errors.Is— стандартная навигация по цепочке.- Тестирование:
result, err := handler.Handle(ctx, cmd); require.NoError(t, err)— нет специального API. - Context propagation: ошибка обёртывается через
%wна каждом уровне — это контракт, не детали.
// ХОРОШО — стандартный паттерн
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (Order, error) {
customer, err := h.customerRepo.FindByID(ctx, cmd.CustomerID)
if err != nil {
return Order{}, fmt.Errorf("create order: customer %s: %w", cmd.CustomerID, err)
}
ord, err := order.New(customer, cmd.Items)
if err != nil {
return Order{}, fmt.Errorf("create order: %w", err)
}
if err := h.repo.Save(ctx, ord); err != nil {
return Order{}, fmt.Errorf("create order: save: %w", err)
}
return ord, nil
}
Каждый return Order{}, err сохраняет полный контекст: edge через errors.As достанет *InsufficientFundsError из любого места цепочки.
R-ERR-RESULT-1 — точечный Result-тип
Result-тип (type Result[T any]) имеет смысл в чисто-функциональных модулях, где ошибка семантически — часть результата вычисления, а не сигнал для edge.
Пример: парсер тарифных правил в core/pricing/:
// core/pricing/rule_parser.go
package pricing
type ParseResult struct {
Rules []Rule
Errors []ParseError
}
type ParseError struct {
Line int
Message string
}
func ParseRules(input string) ParseResult {
var result ParseResult
for i, line := range strings.Split(input, "\n") {
rule, err := parseLine(line)
if err != nil {
result.Errors = append(result.Errors, ParseError{Line: i + 1, Message: err.Error()})
continue
}
result.Rules = append(result.Rules, rule)
}
return result
}
Здесь ParseResult — не замена ([]Rule, error), а другая семантика: парсер продолжает после ошибки и собирает все проблемы. Вызывающий решает — прерваться при первой ошибке или обработать все правила и вернуть агрегированный результат клиенту.
Аналогично — calc engine в core/discount/:
// core/discount/engine.go
type CalcResult struct {
Discount int64
AppliedRule string
Warning string
}
func (e *Engine) Calculate(order Order, customer Customer) CalcResult {
rule := e.findRule(order, customer)
if rule == nil {
return CalcResult{Discount: 0, Warning: "no applicable rule"}
}
return CalcResult{Discount: rule.Apply(order), AppliedRule: rule.Name}
}
Warning в результате — не ошибка flow, а информация для caller'а о качестве вычисления.
Когда panic допустим
R-ERR-RESULT-1: panic для невосстановимых программистских ошибок. Правило: если это можно поймать unit-тестом — panic. Если это может случиться в проде при корректном вводе пользователя — return error.
// core/order/order.go
func New(customerID string, items []Item) (*Order, error) {
if customerID == "" {
return nil, &ValidationError{Field: "customerID", Message: "required"}
}
if len(items) == 0 {
return nil, &EmptyOrderError{}
}
return &Order{
id: uuid.NewString(),
customerID: customerID,
items: items,
status: StatusDraft,
}, nil
}
// core/order/order.go — конструктор агрегата с инвариантом
func mustNewOrderID(raw string) OrderID {
if raw == "" {
panic("orderID cannot be empty — programmer error, check call sites")
}
return OrderID(raw)
}
mustNew* — паттерн для внутреннего конструктора, вызываемого только кодом с контролируемыми входными данными. Префикс must сигнализирует: «это паникует при ошибке — вызывай только с валидными данными».
R-ERR-RESULT-X1 — panic/recover как control-flow
Запрещённый паттерн — имитация try/catch через panic/recover между слоями:
// ПЛОХО — panic как "throw исключения"
func (o *Order) cancel() {
if o.status == StatusShipped {
panic(&OrderAlreadyShippedError{OrderID: o.id}) // ← "throw"
}
o.status = StatusCancelled
}
// ПЛОХО — recover как "catch"
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) (err error) {
defer func() {
if v := recover(); v != nil {
if e, ok := v.(error); ok {
err = e // ← "catch"
} else {
panic(v)
}
}
}()
h.order.cancel()
return nil
}
Почему запрещено:
- Нарушает читаемость.
panicв домене →recoverв handler — это неожиданно для любого, кто читает код. - Разрывает цепочку
%w.panic(err)+recover()+err = e— контекст обёртки теряется. - Ломает
errcheck. Линтер не видитpanicкак возврат ошибки — пишет «ошибка не обработана». Recoverer-middleware поймает первым — еслиCancelOrderHandler.recoverне сработает (например,recoverвызван не напрямую в defer),Recovererна edge превратит это в 500 вместо 422.
Правильно — обычный return err:
// ХОРОШО
func (o *Order) cancel() error {
if o.status == StatusShipped {
return &OrderAlreadyShippedError{OrderID: o.id}
}
o.status = StatusCancelled
return nil
}
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
if err := h.order.cancel(); err != nil {
return fmt.Errorf("cancel order %s: %w", cmd.OrderID, err)
}
return nil
}
R-ERR-RESULT-2 — в цепочке слоёв — только (T, error)
UseCase Handler → Domain → Adapter — везде return err. Нет исключений из этого правила.
// Полная вертикальная цепочка без panic
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) (Order, error) {
customer, err := h.customerRepo.FindByID(ctx, cmd.CustomerID)
if err != nil {
return Order{}, fmt.Errorf("place order: %w", err)
}
products, err := h.productPort.FindProducts(ctx, cmd.ProductIDs)
if err != nil {
return Order{}, fmt.Errorf("place order: %w", err)
}
ord, err := order.Place(customer, products, cmd.DeliveryAddress)
if err != nil {
return Order{}, fmt.Errorf("place order: %w", err)
}
if err := h.paymentPort.Reserve(ctx, payment.ReserveCommand{
OrderID: ord.ID(),
Amount: ord.TotalAmount(),
IdempotencyKey: ord.ID(),
}); err != nil {
return Order{}, fmt.Errorf("place order: reserve payment: %w", err)
}
if err := h.orderRepo.Save(ctx, ord); err != nil {
return Order{}, fmt.Errorf("place order: save: %w", err)
}
return ord, nil
}
Каждая строка return Order{}, fmt.Errorf("...: %w", err) добавляет уровень контекста. Edge получит цепочку вида "place order: reserve payment: payment gateway register: upstream status 503" — достаточно для диагностики без открытия отдельного экрана трейса.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
panic(err) в домене как бизнес-правило | R-ERR-RESULT-X1 | return nil, &SomeDomainError{...} |
recover() в UseCase Handler | R-ERR-RESULT-X1 | только Recoverer-middleware на edge |
Глобальный Result[T] вместо (T, error) | R-ERR-RESULT-X1 | стандартный (T, error) в цепочке слоёв |
panic(fmt.Sprintf("not found: %s", id)) | R-ERR-RESULT-X1 | return T{}, &NotFoundError{ID: id} |
Куда дальше
- Иерархия ошибок — типизированные структуры, которые возвращаются вместо
panic. - Где return, где обрабатывать —
Recoverer-middleware как backstop дляpanic. - Mapping в ProblemDetails —
RecovererвызываетWritePanic→ 500. - Логирование ошибок —
RecovererлогируетpanicкакErrorContextсо стектрейсом. - Retry-семантика —
panicне retry-able черезretry.Do. - Observability ошибок —
panicинкрементитapp_errors_total{type="unexpected"}. - Shared-контракт R-ERR-RESULT — нормативные формулировки.