Go-стиль
Правила, по которым пишется Go-код в проекте. Это дополнение к общепринятым Go-гайдам, а не их замена. Обязательно прочитай публичные гайды:
Если что-то не описано здесь — действует общепринятая практика из этих гайдов.
Именование
Файлы и пакеты
- Имя пакета совпадает с именем папки: папка
service/→package service. - Имя пакета — одно слово, нижний регистр, без подчёркиваний и CamelCase:
handler,postgres,watermill, но НЕhttp_handler,userService. - Файлы —
snake_case.go:review_service.go,http_helpers.go. - Тесты рядом с кодом:
review_service.go→review_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делает это автоматически.