Skip to Content
ConventionsGo style

Go-стиль

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

Если что-то не описано здесь — действует общепринятая практика из этих гайдов.

Именование

Файлы и пакеты

  • Имя пакета совпадает с именем папки: папка service/package service.
  • Имя пакета — одно слово, нижний регистр, без подчёркиваний и CamelCase: handler, postgres, watermill, но НЕ http_handler, userService.
  • Файлы — snake_case.go: review_service.go, http_helpers.go.
  • Тесты рядом с кодом: review_service.goreview_service_test.go.

Идентификаторы

  • Exported (PascalCase): UserRepo, NewAuthService, ErrNotFound.
  • Unexported (camelCase): hashPassword, claimsFromCtx.
  • Константы — такие же: MaxRetries, defaultTimeout. Не MAX_RETRIES.
  • Сокращения сохраняют регистр целиком: userID, HTTPClient, URLParser, но НЕ userId, HttpClient, UrlParser.
  • Интерфейсы — по роли, не по реализации: Publisher, TokenChecker, ReviewRepository. Одно-методные интерфейсы — суффикс -er (Reader, Marshaler).

JSON и SQL — snake_case

JSON-теги всегда snake_case:

type CreateReviewRequest struct { PlaceID int64 `json:"place_id" validate:"required,gt=0"` Rating int16 `json:"rating" validate:"required,min=1,max=5"` Text string `json:"text,omitempty" validate:"max=2000"` }

Колонки таблиц — тоже snake_case: created_at, place_id, is_active.

Обработка ошибок

Полные правила по sentinel-значениям, wrapping через %w, errors.Is / errors.As, кастомным типам и маппингу на HTTP — в error-handling. Здесь — только stylistic- минимум, без которого go-style неполный.

  • Всегда оборачивай через %w: fmt.Errorf("load user %d: %w", id, err). Никогда %s / %v для ошибки, которую надо будет проверить.
  • Префикс в fmt.Errorf — императив, нижний регистр: "get user", а не "Error getting user".
  • Sentinel-ошибки объявляй пакетными переменными (var ErrXxx = errors.New(...)) в errors.go того слоя, где они рождаются.
  • Сравнение — только errors.Is / errors.As. Никаких err == ErrX и никаких type-assertion’ов (err.(*MyErr)).

Никаких голых panic в прод-коде

  • panic допустим только в init() или в main.go при фатальной ошибке старта (например, не загрузился обязательный конфиг).
  • В handler’ах, service’ах, repository — возвращай error. Поймай panic из third-party через middleware.Recoverer (chi), а не заворачивай в recover вручную.
  • В тестах t.Fatal — это не panic, это нормально.

Context

context.Context — первый параметр

// хорошо func (r *UserRepo) Get(ctx context.Context, id int64) (*domain.User, error) // плохо — ctx не первый func (r *UserRepo) Get(id int64, ctx context.Context) (*domain.User, error) // плохо — нет ctx вообще для I/O операции func (r *UserRepo) Get(id int64) (*domain.User, error)

context.Context НЕ храни в struct

// хорошо type Worker struct { /* ... */ } func (w *Worker) Run(ctx context.Context) error { ... } // плохо type Worker struct { ctx context.Context // никогда так не делай }

Исключение — узкое: фоновая goroutine с собственным lifecycle, которая стартует NewX + отдельным методом Run/Close и должна помнить отменяющий context между вызовами (cancellable subscription, SSE-stream на стороне handler’а, errgroup-worker внутри background-сервиса). Даже в этих случаях предпочтительнее:

type Subscription struct { cancel context.CancelFunc done chan struct{} } func NewSubscription(parent context.Context, ...) *Subscription { ctx, cancel := context.WithCancel(parent) s := &Subscription{cancel: cancel, done: make(chan struct{})} go s.loop(ctx) return s } func (s *Subscription) Close() { s.cancel() <-s.done }

Хранится cancel-функция и канал done, не сам ctx. Это сохраняет инварианты:

  • никто не зовёт метод с «чужим» ctx поверх хранимого (типичная ошибка: handler передаёт request-ctx, struct игнорирует и работает со своим — при cancel request’а goroutine продолжает работать);
  • Close строго детерминирован и не зависит от того, что клали в ctx.Value.

Случаи, где поле ctx запрещено всегда:

  • Service/repository/handler struct’ы — они per-call принимают ctx первым аргументом.
  • «Scoped logger» в struct’е — логгер живёт в ctx, не в struct’е. Вытаскивается через log.FromCtx(ctx) внутри метода.
  • «Чтобы не тащить ctx в каждый метод» — это звонок пересмотреть дизайн, а не сохранять ctx.

Linter-правило: containedctx в golangci-lint. Исключения — только в nolint-комментарии с обоснованием в той же строке.

context.Value — только для request-scoped

Разрешено:

  • user ID из JWT после middleware,
  • request ID, correlation ID,
  • locale / trace headers.

Запрещено:

  • передавать бизнес-параметры (filter, page size) через context,
  • передавать зависимости (репозиторий, клиент) через context.

Ключи контекста — unexported-типы:

type ctxKey int const ctxKeyUserID ctxKey = iota

Это предотвращает коллизии между пакетами.

Структура функций

Короткие функции

Если функция > 50 строк или > 3 уровней вложенности — декомпозируй. Обычно это сигнал, что внутри прячется два-три шага, каждый из которых заслуживает отдельной функции.

Ранний выход

// хорошо if err != nil { return fmt.Errorf("load: %w", err) } if user.Banned { return ErrUserBanned } // happy-path продолжается без вложений

Не пиши глубокие else-лестницы.

Именованные return-значения — умеренно

Используй именованные returns, когда хочешь задокументировать семантику:

func (r *UserRepo) Get(ctx context.Context, id int64) (user *domain.User, err error) { // ... }

Не используй их ради «naked return» в середине длинной функции. Это путает.

Интерфейсы

Принимай интерфейс, возвращай конкретный тип

// service зависит от интерфейса type UserRepo interface { Get(ctx context.Context, id int64) (*domain.User, error) } func NewAuthService(repo UserRepo) *AuthService { ... } // фабрика репозитория возвращает конкретный тип func NewPostgresUserRepo(pool *pgxpool.Pool) *UserRepo { ... }

Это упрощает тестирование (в тестах пробрасывается fake, реализующий интерфейс) и не плодит лишних обёрток.

Интерфейс — минимально маленький

Интерфейсы определяются на стороне consumer’а. Если AuthService использует только Get и Create, то интерфейс UserRepo содержит только эти два метода, даже если PostgresUserRepo умеет больше.

Struct’ы

Zero-value должен быть usable — где это разумно

// хорошо: можно создать &Limiter{} и работать с дефолтами type Limiter struct { perMinute int // 0 = unlimited } // плохо: zero-value упадёт при первом вызове type HTTPClient struct { client *http.Client // обязан быть не nil }

Если zero-value нежизнеспособен — скрой struct и требуй конструктор (NewHTTPClient).

Конструкторы NewXxx

Любой тип с нетривиальной инициализацией получает NewXxx:

func NewAuthService( db TxRunner, users UserRepo, sessions SessionRepo, publisher Publisher, signer *token.Signer, ) *AuthService { return &AuthService{ /* ... */ } }

Конструктор:

  • валидирует обязательные параметры (возвращай error, если что-то nil),
  • задаёт дефолты (timeout, batch size),
  • не делает I/O (не шлёт запросы, не создаёт файлы).

Global state

  • Глобальные переменные запрещены кроме main.go и объявления sentinel-ошибок (var ErrX = errors.New(...)).
  • Никаких var DB *pgxpool.Pool в internal/. Зависимость передаётся через конструктор.
  • Никакого init() для настройки глобальных синглтонов. init() допустим только для регистрации чего-то в сторонней библиотеке (например, database/sql драйвер) — и то лучше обойтись без этого.

Concurrency

  • Одна goroutine — один владелец канала. Закрывает канал тот, кто пишет.
  • Никаких go func() без явного плана, как эта goroutine завершится.
  • Все long-running goroutine принимают ctx и завершаются по ctx.Done().
  • При fan-out используй errgroup.WithContext или sync.WaitGroup + буферизованный канал ошибок.
  • Никаких time.Sleep как способа синхронизации. Если ждёшь события — используй канал или context.WithTimeout.

errgroup для параллельных задач

Параллельные goroutines, ждущие общего результата или отменяющиеся по первой ошибке — через golang.org/x/sync/errgroup.

  • errgroup.WithContext(ctx) — отмена любой задачи отменяет остальные через производный gctx.
  • g.Wait() — вернёт первую нетривиальную ошибку и дождётся завершения остальных goroutines.
func fetchReview(ctx context.Context, id string) (*Review, error) { g, gctx := errgroup.WithContext(ctx) var author *Author g.Go(func() error { a, err := authors.Get(gctx, id) if err != nil { return fmt.Errorf("author: %w", err) } author = a return nil }) var media []Media g.Go(func() error { m, err := mediaClient.List(gctx, id) if err != nil { return fmt.Errorf("media: %w", err) } media = m return nil }) if err := g.Wait(); err != nil { return nil, err } return &Review{Author: author, Media: media}, nil }

Правила:

  • Ограничивай max concurrency через g.SetLimit(N) при fan-out > 10 задач — иначе из одного запроса получишь тысячу параллельных HTTP- вызовов в downstream.
  • Не используй голый go func() { ... }() в handler’ах: нельзя дождаться завершения, нельзя пробросить ошибку, нельзя отменить по ctx.

Стоимость fmt.Errorf и wrapping в hot path

fmt.Errorf("prefix: %w", err) — не бесплатная операция: одна аллокация *fmt.wrapError, ~200 ns на форматирование строки. При 10k RPS это ~20 ms CPU в секунду на один wrap. Обычно незаметно, но в узком hot path (Kafka consumer per-message, HTTP handler под SLI latency, tight retry loop) — наблюдаемо в профиле.

Правила:

  • В hot path (consumer per-message, HTTP handler под latency-SLI) оборачивай только в граничных слоях: repo → service → handler, по одному wrap на слой.

  • Не оборачивай в каждом вспомогательном методе — читаемость падает, stacktrace в логе становится шумным.

  • Sentinel-ошибки без контекста — объявляй пакетным var ErrXxx = errors.New("..."). Не fmt.Errorf ради «красивого» текста.

  • Не форматируй данные в wrap: вместо fmt.Errorf("user %s: %w", email, err) (PII-утечка в лог + аллокация на каждый вызов) возвращай ошибку с константным префиксом, а данные клади в structured log отдельными полями:

    if err := s.repo.Get(ctx, id); err != nil { log.FromCtx(ctx).Error("get user", "user_id", id, "err", err) return fmt.Errorf("get user: %w", err) }

В обычном error-path wrap не оптимизируем — читаемость важнее наносекунд.

Тестируемость

  • Service-методы принимают интерфейсы (см. выше) — это уже даёт тестируемость без mock-библиотек.
  • Избегай time.Now() прямо в коде: заведи поле clock func() time.Time в struct, дефолтом time.Now. В тестах подставляется фиксированное время.
  • Не используй глобальный rand. Заведи *rand.Rand в struct.

TODO / FIXME

  • TODO допустим, только если рядом есть ссылка на issue: // TODO(#123): ....
  • FIXME без issue — блокирует merge. Ревьюер имеет право потребовать либо починку, либо ссылку на issue, либо удаление FIXME.
  • Не оставляй закомментированный код. Удаляй — история есть в git.

Форматирование

  • gofmt/goimports — обязателен. Запускается pre-commit hook’ом.
  • Длина строки — мягкий лимит 120 символов. Жёсткого лимита нет, но если строка длинная из-за большого количества аргументов — переноси каждый аргумент на отдельную строку.
  • Блок импортов — три группы через пустую строку: stdlib → внешние → внутренние проектные. goimports делает это автоматически.
Last updated on