Files
evening_detective_server/docs/GO_STANDARD.md
T
2026-10-03 13:22:11 +07:00

23 KiB
Raw Blame History

Стандарт Go-бэкенда

Обязательный стандарт для Go-кода в evening_detective_server. Он описывает основной вариант решения; отклонение требует разрешения пользователя и короткого письменного обоснования в задаче или рядом с изменением.

1. Основной принцип

Пиши самый маленький код, который явно выражает бизнес-правило. Перед новым типом, интерфейсом или пакетом проверь, нельзя ли использовать существующий код или стандартную библиотеку.

Перед изменением функции найди всех её callers и существующий паттерн использования. Исправляй общее место, через которое проходят все callers, а не только первый найденный путь.

Не добавляй:

  • абстракцию с одной реализацией без границы подмены;
  • исключение: для internal/modules интерфейс обязателен даже при одной реализации;
  • общий пакет для одной функции;
  • конфигурацию для значения, которое не меняется;
  • дублирующую валидацию в каждом handler-е;
  • DTO, если существующая структура уже является контрактом нужного слоя.

2. Структура и зависимости

api/                         protobuf-контракт
proto/                       сгенерированный Go-код
internal/app/                gRPC/HTTP handlers и mapping транспорта
internal/services/<name>/    прикладные сценарии и бизнес-правила
internal/repos/<name>/       PostgreSQL и модели хранения
internal/modules/<name>/     переиспользуемая техническая возможность
migrations/                  изменения схемы базы данных
cmd/<name>/                  сборка и запуск приложения

Допустимое направление зависимостей:

cmd → app → services → repos
  └──────────────→ modules
  • modules — технический слой, доступный из любого уровня (cmd, app, services, repos). proto используется как транспортный контракт.
  • Прямые зависимости app от repos запрещены в production-коде handlers и mapping; прикладные операции идут через services.
  • cmd/<name>/main.go — composition root: для инициализации и wiring он может импортировать любой внутренний слой (app, services, repos, modules).
  • services не импортируют app и proto.
  • repos не знают о protobuf, HTTP, gRPC, ролях транспорта и UI.
  • modules не импортируют app, services и repos, не знают о конкретном use case приложения и могут зависеть только от стандартной библиотеки и других технических модулей без циклических зависимостей. Если код нужен только одному сервису, он остаётся в этом сервисе.
  • Циклические зависимости запрещены; не исправляй их дополнительным common-пакетом без конкретной общей ответственности.

Новый пакет получает имя по ответственности в snake_case, как принято в репозитории: game_service, teams_repo, file_storage. Не используй utils, helpers, common и misc для несвязанных функций.

3. Handler (internal/app)

Handler делает только транспортную работу:

  1. получает request и context.Context;
  2. извлекает пользователя, роль и параметры запроса;
  3. вызывает один прикладной метод сервиса;
  4. преобразует результат в protobuf response;
  5. возвращает ошибку через существующий gRPC/gateway-механизм.

Handler не содержит SQL, транзакций, правил перехода состояний, расчётов и проверок, которые должны быть одинаковыми для нескольких callers.

Именуй методы по protobuf RPC. Вспомогательные mapping-функции держи в отдельном *_mapper.go рядом с handler-ом; они не должны выполнять запросы в базу. Данные, которые нельзя показывать конкретному caller-у, фильтруй на границе выдачи или в сервисе — в зависимости от того, является ли это транспортным представлением или бизнес-правилом доступа.

func (s *server) GetGame(ctx context.Context, req *proto.GetGameReq) (*proto.GetGameRsp, error) {
	game, err := s.gameService.Get(ctx, req.GetID(), currentUserID(ctx))
	if err != nil {
		return nil, err
	}
	return mapGetGameResponse(game), nil
}

// game_mapper.go
func mapGetGameResponse(game *Game) *proto.GetGameRsp {
	return &proto.GetGameRsp{Game: &proto.Game{ID: int32(game.ID), Name: game.Name}}
}

4. Service (internal/services)

Сервис представляет один прикладной сценарий или связную область. В нём находятся:

  • валидация входных данных и бизнес-инварианты;
  • проверка прав, если она относится к правилу операции;
  • orchestration нескольких repositories/modules;
  • границы транзакции, когда операция должна быть атомарной;
  • преобразование данных хранения в модель, нужную use case.

Публичные методы сервиса принимают context.Context первым аргументом, возвращают результат и error, не принимают protobuf-типы и не формируют HTTP/gRPC-ответы.

Называй сервис по области (ScenarioService), а методы — по действию (Create, Get, Update, Delete, List). Не создавай интерфейс сервиса только ради mock-а: интерфейс нужен на границе, где есть подмена или его использует другой пакет как зависимость. Интерфейс объявляет потребитель; для внутреннего concrete-сервиса используй структуру напрямую, если другого варианта нет.

Повторяющееся правило размещай в общем сервисном методе или repository, через который проходят все callers. Не копируй guard в handlers.

Mapping-функции не определяй внутри методов сервиса. Выноси их в отдельные функции и файл *_mapper.go рядом с сервисом. Mapping только преобразует типы: он не выполняет I/O, не меняет состояние и не принимает бизнес-решения.

// service.go
func (s *Service) Get(ctx context.Context, id int) (*Game, error) {
	game, err := s.games.GetGameByID(ctx, id)
	if err != nil {
		return nil, err
	}
	return mapStoredGame(game), nil
}

// service_mapper.go
func mapStoredGame(game *games_repo.Game) *Game {
	return &Game{ID: game.ID, Name: game.Name}
}

5. Repository (internal/repos)

Repository — единственное место для SQL и обращения к PostgreSQL. Он:

  • принимает context.Context;
  • использует параметризованные запросы, никогда не склеивает значения в SQL;
  • возвращает типы своей предметной области и ошибки;
  • проверяет rows.Err() после итерации;
  • закрывает rows через defer сразу после успешного запроса;
  • переводит известные ошибки драйвера в sentinel-ошибки пакета.

SQL держи рядом с методом, которому он нужен. Не вводи query builder и общий репозиторный слой без повторяющейся проверенной потребности.

Методы репозитория не проверяют права пользователя и не знают о protobuf. Уникальность, внешние ключи и другие инварианты, которые база может обеспечить надёжно, фиксируй также ограничением или индексом миграции.

Изменение схемы оформляй отдельным SQL-файлом с уникальным timestamp-именем, совместимым с предыдущей схемой. Изменение модели и миграции делай в одной задаче.

6. Modules (internal/modules)

Модуль содержит техническую или переиспользуемую возможность, независимую от конкретного прикладного сценария: хранилище файлов, JWT, отправка email, очистка текста, генерация PDF.

Каждый модуль разделяй на интерфейс и реализацию. Интерфейс размещай в его пакете, реализацию держи непубличной, а конструктор возвращай как интерфейс. Не превращай каждый маленький helper в модуль; если код уже является модулем, для него интерфейс обязателен.

7. Типы, mapping и protobuf

  • Protobuf — внешний транспортный контракт. Сгенерированные файлы в proto/ и Swagger вручную не редактируй.
  • В protobuf используй ID, <name>ID и URL для полей с соответствующими аббревиатурами. Генератор тогда создаёт ID, GameID, URL в Go и ID, gameID, URL в TypeScript.
  • Поля каждого message объявляй в порядке возрастания номеров. Номера уже опубликованных полей не меняй и не переиспользуй; удалённые номера оставляй зарезервированными.
  • Новые HTTP-методы добавляй через api/main.proto, затем запускай генерацию make generate.
  • Преобразование proto ↔ domain/storage находится в app или пакете, которому принадлежат обе модели; бизнес-правила в mapping не прячь.
  • Поля структур в mapping-литералах располагай вертикально: одно поле на строку.
  • Любой mapping, принадлежащий сервису, находится в отдельной функции и файле *_mapper.go, а не внутри метода сервиса.
  • Модель, которой владеет один пакет, объявляй в этом пакете. Не создавай дубликаты структуры в internal/repos и internal/services без различий ответственности.
  • Публичные типы, функции и поля документируй, если их назначение не очевидно из имени. Непубличные имена предпочтительнее, если символ не нужен за пределами пакета.
  • Используй int для идентификаторов, если контракт проекта не требует другого типа; не добавляй конвертацию без границы API или базы.

8. Ошибки и context

  • Возвращай ошибки, не паникуй в обработке пользовательского ввода, БД, файлов, сети и внешних сервисов. panic допустим только для невозможного нарушения внутреннего инварианта при старте или в коде, который не имеет осмысленного способа продолжить работу.
  • Ошибкой владеет пакет, которому принадлежит её смысл. Ожидаемые состояния объявляй как sentinel-ошибки (var Err... = errors.New(...)) в сервисе, repository или module; не дублируй одну смысловую ошибку в разных слоях.
  • Repository переводит ошибки драйвера и ограничения БД в свои предметные ошибки. Service переводит их в ошибки use case. app не меняет бизнес-смысл ошибки, а только преобразует её в транспортный ответ.
  • Для ожидаемых веток проверяй цепочку через errors.Is; для ошибок с данными используй errors.As. Текст ошибки не сравнивай.
  • Оборачивай ошибку с контекстом через %w, например fmt.Errorf("загрузить сценарий %d: %w", id, err). Не теряй исходную ошибку без причины и не оборачивай её повторно пустым или одинаковым сообщением.
  • status.Error и status.Errorf используй только на транспортной границе (internal/app или gRPC middleware). Services, repositories и modules не возвращают gRPC-ошибки.
  • Преобразование ошибок use case в gRPC-коды держи в *_errors.go рядом с handler-ом. Для неизвестной ошибки возвращай codes.Internal с безопасным общим сообщением; детали причины сохраняй в цепочке и логируй на внешней границе.
  • Клиенту нельзя отдавать SQL, пути файлов, адреса внешних сервисов, stack trace, токены, пароли или персональные данные. Ошибки валидации могут содержать только безопасные сведения, необходимые клиенту.
  • Логируй ошибку один раз на границе, которая принимает решение о внешнем ответе; нижние слои добавляют контекст через %w, но не дублируют лог.
  • Каждый внешний вызов получает переданный context.Context; timeout нужен на границе долгой операции или фонового worker-а. Отмену контекста не скрывай: возвращай ctx.Err() или оборачивай её через %w.
  • Не помещай обязательные параметры операции в context. В context допустимы только request-scoped данные, уже принятые проектным middleware.
  • Для каждой новой ветки бизнес-ошибки тестируй errors.Is и итоговый транспортный код; для неизвестной ошибки проверяй, что наружу уходит codes.Internal без внутренних деталей.

9. SQL и транзакции

  • SQL-шаблоны пиши в многострочном raw string с отступами и переносами по смысловым частям; не записывай запрос одной длинной строкой.
  • Вызов Query, QueryRow или Exec оформляй многострочно: сначала ctx, затем SQL, затем каждый аргумент запроса на отдельной строке и закрывающая скобка.
  • В SELECT перечисляй поля вертикально; FROM, JOIN, WHERE, ORDER BY, GROUP BY, ON CONFLICT и RETURNING начинай с отдельных строк. Не склеивай несколько SQL-клауз в одну строку.
  • Запросы должны быть читаемыми: явные поля вместо SELECT *, параметры вместо конкатенации.
  • Все Query-результаты закрывай; ошибки итерации проверяй.
  • Несколько изменений, которые должны пройти вместе, выполняй в одной транзакции. Не имитируй транзакцию последовательностью независимых вызовов.
  • Не добавляй транзакцию для одной простой операции без требования атомарности.
  • Конкурентные ограничения обеспечивай базой (UNIQUE, CHECK, FK) и корректно обрабатывай конфликт в Go.

10. Тесты

Тест размещай рядом с тестируемым пакетом в файле *_test.go.

  • Чистые функции и mapping тестируй без базы и сети.
  • Бизнес-правила сервиса тестируй через минимальные fake/stub-зависимости; тест не должен поднимать PostgreSQL, если это не проверка SQL или транзакции.
  • SQL, ограничения и атомарность проверяй интеграционным тестом с отдельной тестовой базой через TEST_DATABASE_URL.
  • Не пиши SQL-запросы в тестовом сценарии и не обращайся к базе напрямую через Query, QueryRow или Exec. Подготовку данных и проверки выполняй через реальные публичные методы тестируемого сервиса или repository. Схему можно подготовить миграциями инфраструктурного слоя, но тест не должен подменять ими методы приложения или проверять результат прямым SQL-запросом.
  • Для endpoint-ов проверяй только транспортный mapping и доступ; полный бизнес-сценарий принадлежит тестам сервиса.
  • Имена тестов описывают поведение: TestCreateRejectsFinishedGame.
  • Все тесты оформляй как табличные, даже если в таблице только один кейс.
  • Любая новая ветка с бизнес-правилом, SQL-ограничением или безопасностью должна иметь минимальный тест, который ломается при регрессии.

Команды проверки:

gofmt -w <изменённые-go-файлы>
go test ./...

После изменения protobuf дополнительно запускай make generate и проверяй сгенерированный diff.

11. Именование и стиль

  • Все комментарии в исходном Go-коде пиши на английском языке. Сгенерированные файлы и комментарии внешних библиотек не редактируй.
  • Имена короткие и предметные: gameID, teamID, ctx, err допустимы по общему Go-стилю.
  • Аббревиатуры пишутся единообразно: ID, URL, HTTP, JWT, SQL.
  • Используй префикс I в именах интерфейсов: IFileStorage, IEmailSender.
  • Не прячь побочные эффекты в функциях с именами Get, Map, Parse.
  • Сервисы и конструкторы называй по предметной области: ScreenService и NewScreenService; generic-имена Service и New запрещены.
  • New... создаёт значение и возвращает ошибку только если создание реально может завершиться ошибкой.
  • Форматируй код gofmt; не добавляй ручное форматирование, генераторы и линтеры без требования проекта.

12. Как выбрать место для нового кода

Вопрос Место
Как принять RPC/HTTP и вернуть protobuf? internal/app
Какое бизнес-условие разрешает операцию? internal/services
Как прочитать или изменить PostgreSQL? internal/repos
Как переиспользовать S3, JWT, email или PDF? internal/modules
Как изменить внешний контракт? api/, затем генерация
Как изменить схему? migrations/
Как преобразовать типы транспорта? рядом с handler-ом или владельцем mapping
Как проверить поведение? *_test.go рядом с владельцем поведения

Если ответ не помещается в одну строку, сначала уточни границу ответственности, а не создавай новый слой.