Опирается на правила: R-ERR-RESULT-1R-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-X1return nil, &SomeDomainError{...}
recover() в UseCase HandlerR-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-X1return 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 — нормативные формулировки.