Структура Go-проекта#
Модуль: Backend · Уровень: Middle+/Senior
TL;DR#
В Go нет официального стандарта раскладки каталогов; популярный golang-standards/project-layout — не документ команды Go и подходит не всем. Единственное, что закреплено в языке, — каталог internal/: его содержимое компилятор запрещает импортировать извне модуля. Пакет в Go — это единица инкапсуляции и единица зависимости, поэтому раскладка определяется не эстетикой, а графом импортов: циклы между пакетами запрещены компилятором, и это главное ограничение дизайна. Здоровый подход — плоская структура для маленького сервиса, деление по предметной области (а не по техническим слоям) для крупного, cmd/ для точек входа и отсутствие пакетов-помоек utils, common, models.
Простыми словами#
Первый вопрос новичка в Go: «где официальная структура проекта?» Ответа нет — команда Go её не публиковала, а популярный репозиторий project-layout, который выпадает в поиске, к официальным не относится и справедливо критикуется за избыточность.
Что действительно закреплено в языке — это internal/. Всё, что лежит внутри такого каталога, невозможно импортировать снаружи модуля: не по соглашению, а по запрету компилятора. Это единственный настоящий механизм «приватности» уровня модуля, и им стоит пользоваться.
Дальше начинается то, что определяет структуру на самом деле: в Go запрещены циклические импорты. Пакет A импортирует B, B импортирует A — код не соберётся. Поэтому раскладка каталогов — это не вопрос вкуса, а вопрос направления зависимостей, и продумывать её приходится заранее.
Практическое правило простое: маленький сервис живёт одним-двумя пакетами и не нуждается ни в какой иерархии. Растущий сервис делят по предметной области (order, payment, user), а не по техническим слоям (models, services, repositories) — иначе любое изменение одной фичи расползается по четырём каталогам.
Если ты с другого языка. Привычная по Java и C# раскладка по слоям здесь работает плохо: там пакет — просто пространство имён, а в Go это ещё и единица зависимости. И имена вроде utils, helpers, common в Go считаются запахом: пакет должен называться по тому, что он делает, потому что его имя становится частью каждого вызова (order.New, а не models.NewOrder).
Словарь этой статьи:
internal/— каталог, импорт из которого запрещён вне модуля;- граф импортов — кто кого импортирует; в Go он обязан быть ациклическим;
cmd/— соглашение для точек входа (main-пакетов);- пакет-помойка —
utils/common, куда складывают всё подряд.
Теория#
Пакет как единица дизайна#
В Go пакет решает три задачи сразу:
- Инкапсуляция. Экспортируется то, что с большой буквы; остальное недоступно снаружи пакета. Не класс, не модуль — именно пакет является границей видимости.
- Единица зависимости. Импортируется пакет целиком, частично взять нельзя.
- Часть имени. Вызов выглядит как
пакет.Функция, поэтомуstrings.Splitчитается хорошо, аutils.StringSplit— плохо. Отсюда рекомендация не дублировать имя пакета в именах функций.
Главное ограничение: циклы запрещены. Если order импортирует payment, а payment — order, сборка падает. Обойти это можно только одним способом — выделить общий интерфейс или тип в третий пакет, от которого зависят оба. Это ограничение больно бьёт по привычным из ООП-языков схемам с двусторонними ссылками между сущностями, зато не даёт архитектуре превратиться в клубок.
internal: единственная гарантия от компилятора#
myapp/
├── go.mod // module github.com/user/myapp
├── internal/
│ └── storage/ // импортируется только внутри github.com/user/myapp
└── pkg/
└── client/ // импортируется кем угодноПравило: пакет из каталога internal доступен только коду, чей путь начинается с родителя этого internal. Попытка импортировать снаружи — ошибка компиляции, а не замечание линтера.
Практический вывод: по умолчанию клади код в internal/. Публичным делай только то, что действительно является API библиотеки. Иначе любая внутренняя структура превращается в обязательство перед чужими проектами.
Каталог pkg/ — это уже просто соглашение, никакой магии в нём нет. Многие считают его лишним: если код не в internal, он и так публичен, а лишний уровень вложенности удлиняет пути импорта. Разумный компромисс — заводить pkg/ только когда в репозитории действительно есть код, предназначенный для внешних потребителей.
cmd: точки входа#
cmd/
├── api/
│ └── main.go // HTTP-сервер
├── worker/
│ └── main.go // фоновый обработчик
└── migrate/
└── main.go // утилита миграцийСоглашение cmd/<имя>/main.go даёт две вещи: имя каталога становится именем бинарника при go build ./cmd/api, и в одном модуле спокойно уживаются несколько исполняемых файлов, разделяя общий код.
Главное правило для main: он должен быть тонким. Прочитать конфигурацию, собрать зависимости, запустить, дождаться сигнала, корректно остановиться. Бизнес-логики в main быть не должно — её невозможно тестировать.
Три рабочие раскладки#
1. Плоская — для маленького сервиса
myservice/
├── go.mod
├── main.go
├── handler.go
├── store.go
├── store_test.go
└── config.goВсё в одном пакете. Никаких проблем с циклами, всё видит всё, тесты имеют доступ к внутренностям. Для сервиса на пару тысяч строк это лучший вариант, и переусложнять его «на вырост» не надо — разложить по пакетам всегда можно позже, когда границы станут очевидны.
2. По предметной области — для растущего сервиса
myservice/
├── cmd/api/main.go
└── internal/
├── order/ // всё про заказы: модель, логика, хранилище, хендлеры
│ ├── order.go
│ ├── service.go
│ ├── postgres.go
│ └── http.go
├── payment/
├── user/
└── platform/ // общая инфраструктура
├── database/
└── httpx/Каждый пакет — законченный кусок предметной области. Изменение в логике заказов затрагивает один каталог. Границы совпадают с границами бизнеса, а значит и с границами возможного будущего разделения на сервисы.
3. По слоям — для сложной логики
internal/
├── domain/ // сущности и правила, без зависимостей
├── usecase/ // сценарии, зависят только от domain
├── adapter/ // http, grpc, postgres — знают про usecase
└── infra/ // конфиг, логгер, подключенияОправдано, когда бизнес-логика действительно сложная и её важно изолировать от способа доставки и хранения. Подробнее — в статье про чистую архитектуру. Для CRUD-сервиса такая раскладка даёт больше церемоний, чем пользы.
Чего делать не стоит#
Пакеты-помойки. utils, common, helpers, misc — это пакеты без предметной области. Они растут бесконечно, их импортируют все, и они мгновенно становятся источником циклов. Функция должна лежать рядом с тем, к чему относится: работа со строками — в strings-подобном пакете, а не «в общем».
Раскладка «по типу сущности». models/, interfaces/, structs/ — техническая классификация вместо смысловой. Пакет models с сорока структурами импортируется отовсюду и связывает весь проект в один узел.
Преждевременное деление. Восемь пакетов в проекте на тысячу строк не улучшают ничего, зато немедленно создают проблемы с циклами и заставляют экспортировать то, что могло остаться приватным.
Дублирование имени. order/order.go с типом order.Order — читается как заикание. Часто лучше order.Service, order.Repository, order.New.
Куда класть остальное#
Устоявшиеся соглашения, которые встречаются в большинстве репозиториев:
api/ — контракты: .proto, openapi.yaml
migrations/ — SQL-миграции
deployments/ — Dockerfile, k8s-манифесты, helm
scripts/ — вспомогательные скрипты
testdata/ — данные для тестов (имя понимает сам go tool: каталог игнорируется при сборке)
docs/ — документация
tools/ — инструменты сборки, зафиксированные через tools.gotestdata — единственный из них, который имеет специальное значение для тулчейна: каталоги с таким именем игнорируются при сборке.
Тесты и границы пакета#
package order // внутренний тест: видит неэкспортируемое
package order_test // внешний тест: видит только публичный APIВторой вариант полезнее, чем кажется: он заставляет писать тест так, как этим пакетом будет пользоваться настоящий код, и сразу показывает, удобен ли API. Оба файла могут лежать в одном каталоге.
Подводные камни / gotchas#
- Циклический импорт. Компилятор откажется собирать. Лечится выделением общего интерфейса в отдельный пакет либо инверсией зависимости — интерфейс объявляется у потребителя.
internalработает от родителя.a/internal/xдоступен всему внутриa/, а не только соседям. Уровень вложенностиinternalзадаёт область видимости.pkg/не даёт ничего, кроме лишнего уровня в пути. Это соглашение, а не механизм.- Слишком ранние абстракции. Интерфейсы «на будущее», которые имеют ровно одну реализацию, — чистый минус к читаемости.
mainс бизнес-логикой невозможно протестировать.- Имя пакета — часть API. Переименование каталога ломает импорты у всех потребителей.
- Подкаталоги — отдельные пакеты. Вложенность не даёт доступа к неэкспортируемым идентификаторам родителя; в Go нет «подпакетов» в java-смысле.
Вопросы на собеседовании#
В: Есть ли в Go официальный стандарт структуры проекта?
О: Нет. Репозиторий golang-standards/project-layout — не документ команды Go и не является стандартом. Единственное, что закреплено в языке, — семантика каталога internal.
В: Что делает internal/?
О: Запрещает импорт своего содержимого извне поддерева, в котором он лежит. Это проверяет компилятор, поэтому механизм надёжный. Обычно весь код сервиса кладут именно туда, оставляя публичным только то, что действительно предназначено для внешних потребителей.
В: Как решить циклический импорт? О: Либо выделить общую часть (типы, интерфейсы) в третий пакет, от которого зависят оба, либо инвертировать зависимость: объявить интерфейс на стороне потребителя, а реализацию передавать снаружи. Второй способ идиоматичнее.
В: Почему деление по предметной области предпочтительнее деления по слоям? О: Потому что изменения приходят по бизнес-фичам, а не по слоям. При раскладке по областям правка одной фичи затрагивает один пакет, при раскладке по слоям — четыре. Плюс границы областей естественно совпадают с возможными границами будущих сервисов.
В: Чем плох пакет utils?
О: У него нет предметной области, поэтому он растёт бесконечно и импортируется отовсюду, становясь узлом связности и источником циклов. Кроме того, имя пакета участвует в каждом вызове, и utils.DoSomething ничего не сообщает читателю.
В: В чём разница между пакетами foo и foo_test в тестах?
О: Первый видит неэкспортируемые идентификаторы, второй — только публичный API. Внешний тестовый пакет полезен как проверка удобства API и защита от тестирования деталей реализации.
В: Как организовать несколько бинарников в одном модуле?
О: Через cmd/<имя>/main.go для каждого. Общий код лежит в internal/ и переиспользуется; сборка идёт командой go build ./cmd/<имя>.
На что копают на senior+#
- Понимание, что структура определяется графом импортов и запретом циклов, а не эстетикой каталогов.
- Умение обосновать выбор раскладки под размер и срок жизни проекта, включая честное «здесь достаточно плоской структуры».
- Осознанное использование
internalкак механизма контроля публичного API модуля. - Способность распознать преждевременную абстракцию и объяснить, чем она вредна.
- Follow-up: «Как вы делите монорепозиторий на модули?», «Куда класть общие для нескольких сервисов типы?», «Как организовать пакет так, чтобы его было удобно тестировать без моков?».