Опирается на правила:
GO-4.1…GO-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.1 | Guard clause: if err != nil { return ..., err } |
defer внутри if someCondition { defer f() } | GO-4.4 | defer f() сразу после Open/Lock/Begin |
switch без default при неполном перечислении | GO-4.2 | явный default с ошибкой или panic |
Горутина внутри горутины без WaitGroup/errgroup | GO-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.X2 | v := v перед запуском горутины |
Бизнес-логика в init() | GO-4.6 | инициализация в main или конструкторе через New... |
| Функция 200+ строк без разбивки | GO-4.5 | выделить вспомогательные функции по шагам |
Куда дальше
- Именование — именование пакетов, типов, интерфейсов, тестов.
- context.Context —
context.Contextкак первый аргумент, таймауты, значения в контексте. - Конкурентность —
errgroup,semaphore,sync.Mutexvs каналы,go test -race. - /standards/backend/error-handling/go/result-types-vs-exceptions/ — как ошибки-значения работают вместе с guard clause.