Перейти к содержанию

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.md. Здесь — только 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  // никогда так не делай
}

Исключение: если тип — «short-lived per-request» (например, SSE-subscriber), то контекст в нём ОК, но такие типы живут не дольше одного запроса.

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.

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

  • 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 делает это автоматически.