generated from VLADIMIR/template
322 lines
23 KiB
Markdown
322 lines
23 KiB
Markdown
# Стандарт 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
|
||
```
|
||
|
||
- `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-у, фильтруй на
|
||
границе выдачи или в сервисе — в зависимости от того, является ли это
|
||
транспортным представлением или бизнес-правилом доступа.
|
||
|
||
```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-литералах располагай вертикально: одно поле на строку.
|
||
- Любой 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-ограничением или безопасностью
|
||
должна иметь минимальный тест, который ломается при регрессии.
|
||
|
||
Команды проверки:
|
||
|
||
```sh
|
||
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` рядом с владельцем поведения |
|
||
|
||
Если ответ не помещается в одну строку, сначала уточни границу ответственности,
|
||
а не создавай новый слой.
|