Структура 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 пакет решает три задачи сразу:

  1. Инкапсуляция. Экспортируется то, что с большой буквы; остальное недоступно снаружи пакета. Не класс, не модуль — именно пакет является границей видимости.
  2. Единица зависимости. Импортируется пакет целиком, частично взять нельзя.
  3. Часть имени. Вызов выглядит как пакет.Функция, поэтому strings.Split читается хорошо, а utils.StringSplit — плохо. Отсюда рекомендация не дублировать имя пакета в именах функций.

Главное ограничение: циклы запрещены. Если order импортирует payment, а paymentorder, сборка падает. Обойти это можно только одним способом — выделить общий интерфейс или тип в третий пакет, от которого зависят оба. Это ограничение больно бьёт по привычным из ООП-языков схемам с двусторонними ссылками между сущностями, зато не даёт архитектуре превратиться в клубок.

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.go

testdata — единственный из них, который имеет специальное значение для тулчейна: каталоги с таким именем игнорируются при сборке.

Тесты и границы пакета#

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: «Как вы делите монорепозиторий на модули?», «Куда класть общие для нескольких сервисов типы?», «Как организовать пакет так, чтобы его было удобно тестировать без моков?».