20 KiB
Стандарт 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
- По прикладной логике
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 делает только транспортную работу:
- получает request и
context.Context; - извлекает пользователя, роль и параметры запроса;
- вызывает один прикладной метод сервиса;
- преобразует результат в protobuf response;
- возвращает ошибку через существующий 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
- Возвращай ошибки, не паникуй в обработке пользовательского ввода, БД, файлов, сети и внешних сервисов.
- Для ожидаемых веток объявляй 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-ограничением или безопасностью должна иметь минимальный тест, который ломается при регрессии.
Команды проверки:
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. - Сервисы и конструкторы называй по предметной области:
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 рядом с владельцем поведения |
Если ответ не помещается в одну строку, сначала уточни границу ответственности, а не создавай новый слой.