Files
evening_detective_server/docs/GO_STANDARD.md
T
2026-10-01 15:23:22 +07:00

293 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Стандарт Go-бэкенда
Обязательный стандарт для Go-кода в `evening_detective_server`. Он описывает
основной вариант решения; отклонение требует разрешения пользователя и
короткого письменного обоснования в задаче или рядом с изменением.
## 1. Основной принцип
Пиши самый маленький код, который явно выражает бизнес-правило. Перед новым
типом, интерфейсом или пакетом проверь, нельзя ли использовать существующий
код или стандартную библиотеку.
Перед изменением функции найди всех её callers и существующий паттерн
использования. Исправляй общее место, через которое проходят все callers, а не
только первый найденный путь.
Не добавляй:
- абстракцию с одной реализацией без границы подмены;
- исключение: для `internal/modules` интерфейс обязателен даже при одной
реализации;
- общий пакет для одной функции;
- конфигурацию для значения, которое не меняется;
- дублирующую валидацию в каждом handler-е;
- DTO, если существующая структура уже является контрактом нужного слоя.
## 2. Структура и зависимости
```text
api/ protobuf-контракт
proto/ сгенерированный Go-код
internal/app/ gRPC/HTTP handlers и mapping транспорта
internal/services/<name>/ прикладные сценарии и бизнес-правила
internal/repos/<name>/ PostgreSQL и модели хранения
internal/modules/<name>/ переиспользуемая техническая возможность
migrations/ изменения схемы базы данных
cmd/<name>/ сборка и запуск приложения
```
Допустимое направление зависимостей:
```text
cmd → app → services → repos
↘ modules
```
- По прикладной логике `app` зависит только от `services`; `proto` используется
как транспортный контракт. Прямые зависимости от `repos` и `modules` в
handlers и mapping запрещены.
- `cmd` собирает граф зависимостей и может импортировать `services`, `repos` и
`modules` для wiring.
- `services` не импортируют `app` и `proto`.
- `repos` не знают о protobuf, HTTP, gRPC, ролях транспорта и UI.
- `modules` не знают о конкретном 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-у, фильтруй на
границе выдачи или в сервисе — в зависимости от того, является ли это
транспортным представлением или бизнес-правилом доступа.
```go
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, не меняет состояние и не принимает бизнес-решения.
```go
// 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, принадлежащий сервису, находится в отдельной функции и файле
`*_mapper.go`, а не внутри метода сервиса.
- Модель, которой владеет один пакет, объявляй в этом пакете. Не создавай
дубликаты структуры в `internal/repos` и `internal/services` без различий
ответственности.
- Публичные типы, функции и поля документируй, если их назначение не очевидно
из имени. Непубличные имена предпочтительнее, если символ не нужен за
пределами пакета.
- Используй `int` для идентификаторов, если контракт проекта не требует
другого типа; не добавляй конвертацию без границы API или базы.
## 8. Ошибки и context
- Возвращай ошибки, не паникуй в обработке пользовательского ввода, БД,
файлов, сети и внешних сервисов.
- Для ожидаемых веток объявляй sentinel-ошибку в пакете, который владеет
смыслом ошибки; проверяй её через `errors.Is`.
- Оборачивай ошибку с контекстом через `%w`, например
`fmt.Errorf("загрузить сценарий %d: %w", id, err)`.
- Не сравнивай текст ошибки и не теряй исходную ошибку без причины.
- Каждый внешний вызов получает переданный `context.Context`; timeout нужен на
границе долгой операции или фонового worker-а.
- Не помещай обязательные параметры операции в context. В context допустимы
только request-scoped данные, уже принятые проектным middleware.
- Не логируй пароли, токены, персональные данные и содержимое пользовательских
файлов.
## 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-ограничением или безопасностью
должна иметь минимальный тест, который ломается при регрессии.
Команды проверки:
```sh
gofmt -w <изменённые-go-файлы>
go test ./...
```
После изменения protobuf дополнительно запускай `make generate` и проверяй
сгенерированный diff.
## 11. Именование и стиль
- Имена короткие и предметные: `gameID`, `teamID`, `ctx`, `err` допустимы по
общему Go-стилю.
- Аббревиатуры пишутся единообразно: `ID`, `URL`, `HTTP`, `JWT`, `SQL`.
- Используй префикс `I` в именах интерфейсов: `IFileStorage`, `IEmailSender`.
- Не прячь побочные эффекты в функциях с именами `Get`, `Map`, `Parse`.
- `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` рядом с владельцем поведения |
Если ответ не помещается в одну строку, сначала уточни границу ответственности,
а не создавай новый слой.