Опирается на правила: GO-4.1GO-4.6, GO-4.X1, GO-4.X2 из Go Style Guide → раздел 4. Код и управляющие структуры.

Важно знать

  • Guard clause (GO-4.1) — ранний return err вместо вложенных if err == nil { ... }; вложенность не более трёх уровней.
  • defer (GO-4.4) — сразу после Open/Lock/Begin, не в ветви if; иначе ресурс не освободится при ошибке.
  • switch (GO-4.2) — без default только при исчерпывающем перечислении вариантов; во всех остальных случаях default обязателен.
  • Горутины (GO-4.3) — не более одного уровня вложенности без явного sync.WaitGroup / errgroup.
  • Длина функции (GO-4.5) — ориентир 40 строк; превышение сигнализирует о необходимости выделить вспомогательную функцию.
  • init() (GO-4.6) — только для регистрации в глобальном реестре (флаги, promauto-метрики); бизнес-логика в init запрещена.
  • for { select { ... } } (GO-4.X1) — без явного case <-ctx.Done() горутина утечёт при отмене контекста.
  • Захват переменной цикла (GO-4.X2) — go func() { use(v) }() без копирования в for i := 0; ... порождает data race.

Стиль управляющих структур в Go строится вокруг одного принципа: счастливый путь идёт прямо. Проверка ошибок и граничные условия выталкиваются наверх, а не прячутся в глубину вложенных блоков. Это делает любую функцию читаемой сверху вниз без необходимости удерживать в уме стек условий.

Guard clause — ранний return

GO-4.1: вложенность не более трёх уровней.

Классический анти-паттерн — лестница if err == nil:

func (r *OrderRepository) Load(ctx context.Context, id string) (*Order, error) {
    row, err := r.db.QueryRow(ctx, selectOrderSQL, id)
    if err == nil {
        order, err := scanOrder(row)
        if err == nil {
            items, err := r.loadItems(ctx, id)
            if err == nil {
                order.Items = items
                return order, nil
            }
        }
    }
    return nil, err
}

Guard clause переворачивает логику: проверяем ошибку немедленно и выходим:

func (r *OrderRepository) Load(ctx context.Context, id string) (*Order, error) {
    row, err := r.db.QueryRow(ctx, selectOrderSQL, id)
    if err != nil {
        return nil, fmt.Errorf("load order %s: %w", id, err)
    }

    order, err := scanOrder(row)
    if err != nil {
        return nil, fmt.Errorf("scan order %s: %w", id, err)
    }

    items, err := r.loadItems(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("load items for order %s: %w", id, err)
    }

    order.Items = items
    return order, nil
}

Счастливый путь (order.Items = items; return order, nil) читается без ментального переключения между ветвями.

Правило о трёх уровнях — не абсолют, а сигнал: три вложенных if внутри функции обычно означают, что функция делает слишком много и её стоит разбить.

defer — сразу после Open/Lock/Begin

GO-4.4: defer вызывается при выходе из функции, независимо от пути.

Типичная ошибка — defer внутри if:

func processOrder(ctx context.Context, id string) error {
    tx, err := db.Begin(ctx)
    if err != nil {
        return err
    }
    if someCondition {
        defer tx.Rollback(ctx)
    }
}

Правильно — defer сразу после Begin:

func processOrder(ctx context.Context, db *pgxpool.Pool, id string) error {
    tx, err := db.Begin(ctx)
    if err != nil {
        return fmt.Errorf("begin tx: %w", err)
    }
    defer tx.Rollback(ctx)

    if err := updateOrderStatus(ctx, tx, id); err != nil {
        return fmt.Errorf("update status: %w", err)
    }

    if err := tx.Commit(ctx); err != nil {
        return fmt.Errorf("commit: %w", err)
    }
    return nil
}

Rollback после успешного Commit безопасен — транзакция уже завершена, pgx проигнорирует вызов. Этот паттерн не требует флага committed bool и не оставляет незакрытых транзакций.

То же для мьютексов и файлов:

func (s *ProductService) Reserve(id string, qty int) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    product, ok := s.inventory[id]
    if !ok {
        return apperr.NotFound("product", id)
    }
    if product.Stock < qty {
        return &InsufficientStockError{ProductID: id, Requested: qty, Available: product.Stock}
    }
    s.inventory[id].Stock -= qty
    return nil
}

switch — default обязателен

GO-4.2: switch без default допустим только при исчерпывающем перечислении.

Исчерпывающий switch для статусов заказа:

type OrderStatus int

const (
    OrderPending OrderStatus = iota
    OrderConfirmed
    OrderCancelled
)

func statusLabel(s OrderStatus) string {
    switch s {
    case OrderPending:
        return "ожидает"
    case OrderConfirmed:
        return "подтверждён"
    case OrderCancelled:
        return "отменён"
    }
    panic(fmt.Sprintf("unknown OrderStatus: %d", s))
}

Если перечисление неполное или тип может расшириться — default обязателен:

func handleEvent(e Event) error {
    switch e.Type {
    case EventOrderCreated:
        return handleOrderCreated(e)
    case EventPaymentReceived:
        return handlePaymentReceived(e)
    default:
        return fmt.Errorf("unhandled event type %q", e.Type)
    }
}

Пустой default: с намеренным «ничего не делаем» требует минимального годокомментария над функцией, объясняющего почему:

func maybeRoute(status CustomerStatus) {
    switch status {
    case CustomerVIP:
        sendToVIPQueue(status)
    case CustomerBlocked:
        reject(status)
    default:
    }
}

Горутины — не более одного уровня вложенности

GO-4.3: горутины без ожидания теряются при shutdown.

Недопустимо запускать горутину внутри горутины без механизма ожидания:

func (s *NotificationService) NotifyAll(ctx context.Context, customers []Customer) {
    for _, c := range customers {
        go func(c Customer) {
            go func() {
                s.sendEmail(ctx, c)
            }()
        }(c)
    }
}

Правильно — один уровень с errgroup:

func (s *NotificationService) NotifyAll(ctx context.Context, customers []Customer) error {
    g, ctx := errgroup.WithContext(ctx)
    for _, c := range customers {
        c := c
        g.Go(func() error {
            return s.sendEmail(ctx, c)
        })
    }
    return g.Wait()
}

errgroup.WithContext из golang.org/x/sync/errgroup даёт отмену при первой ошибке и механизм сбора результатов.

Горутина и контекст — выход по ctx.Done()

GO-4.X1: for { select { ... } } без case <-ctx.Done() — горутина утечёт.

Типичный воркер:

func (w *OrderWorker) Start(ctx context.Context) {
    go func() {
        for {
            select {
            case msg := <-w.messages:
                w.process(msg)
            case <-ctx.Done():
                return
            }
        }
    }()
}

Без case <-ctx.Done() горутина работает вечно — при shutdown сервиса она не завершится, и диагностика через pprof /goroutines покажет утечку.

Захват переменной цикла

GO-4.X2: в for i := 0; i < n; i++ переменная i — одна на всю итерацию цикла.

funcs := make([]func(), 3)
for i := 0; i < 3; i++ {
    i := i
    funcs[i] = func() { fmt.Println(i) }
}

Без i := i все три замыкания захватят ссылку на одну переменную i и распечатают 3 3 3.

В Go 1.22+ семантика for range изменена — переменная диапазона создаётся заново на каждой итерации. Но для классического for i := 0; ... паттерн с копией остаётся обязательным.

Длина функции — ориентир 40 строк

GO-4.5: если функция не помещается на экране, она, вероятно, делает слишком много.

40 строк — ориентир, не жёсткий лимит. Функции-оркестраторы (например, UseCase.Execute) могут быть длиннее — но только если они организуют вызовы, а не содержат логику.

Признаки функции, которую стоит разбить:

  • Три и более уровня вложенности.
  • Несколько // блоков с пояснениями «что делаем дальше».
  • Один return в самом конце после длинной последовательности шагов без промежуточных проверок.
func (uc *CreateOrderUseCase) Execute(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
    if err := uc.validator.Validate(cmd); err != nil {
        return nil, fmt.Errorf("validate: %w", err)
    }

    customer, err := uc.customers.Get(ctx, cmd.CustomerID)
    if err != nil {
        return nil, fmt.Errorf("get customer %s: %w", cmd.CustomerID, err)
    }

    order, err := buildOrder(customer, cmd)
    if err != nil {
        return nil, fmt.Errorf("build order: %w", err)
    }

    if err := uc.orders.Save(ctx, order); err != nil {
        return nil, fmt.Errorf("save order: %w", err)
    }

    uc.events.Publish(ctx, OrderCreatedEvent{OrderID: order.ID})
    return order, nil
}

buildOrder выделена отдельной функцией — оркестратор читается как последовательность шагов.

init() — только для регистрации

GO-4.6: init() запускается до main, получает среду без гарантий, не может вернуть ошибку.

Допустимо:

var requestsTotal = promauto.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests",
    },
    []string{"method", "path", "status"},
)

promauto регистрирует метрику через init внутри себя — это именно тот случай, для которого init предназначен.

Недопустимо — открытие соединения с базой или загрузка конфига в init:

func init() {
    db, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    globalDB = db
}

Такой init не поддаётся тестированию, скрывает зависимости и не позволяет нормально завершить работу при ошибке.

Что запрещено

АнтипаттернПравилоЧто взамен
if err == nil { if err == nil { ... } } — лестницаGO-4.1Guard clause: if err != nil { return ..., err }
defer внутри if someCondition { defer f() }GO-4.4defer f() сразу после Open/Lock/Begin
switch без default при неполном перечисленииGO-4.2явный default с ошибкой или panic
Горутина внутри горутины без WaitGroup/errgroupGO-4.3один уровень + errgroup.WithContext
for { select { case msg := <-ch: ... } } без ctx.Done()GO-4.X1добавить case <-ctx.Done(): return
go func() { use(v) }() в for i := 0; ... без копииGO-4.X2v := v перед запуском горутины
Бизнес-логика в init()GO-4.6инициализация в main или конструкторе через New...
Функция 200+ строк без разбивкиGO-4.5выделить вспомогательные функции по шагам

Куда дальше

  • Именование — именование пакетов, типов, интерфейсов, тестов.
  • context.Context — context.Context как первый аргумент, таймауты, значения в контексте.
  • Конкурентность — errgroup, semaphore, sync.Mutex vs каналы, go test -race.
  • /standards/backend/error-handling/go/result-types-vs-exceptions/ — как ошибки-значения работают вместе с guard clause.