16. Standard Go Project Layout и его ограничения

Эволюция структуры Go-проекта

Когда программист впервые знакомится с популярными репозиториями на GitHub или ищет признанные «best practices» по организации исходного кода на Go, он почти неизбежно сталкивается с характерным лабиринтом директорий: cmd/, internal/, pkg/, api/, deployments/ и scripts/.

Эта схема широко известна в индустрии как Standard Go Project Layout. Она фактически обрела статус негласного отраслевого стандарта (de facto standard), хотя — и об этом критически важно помнить с первых же строк — никогда официально не утверждалась командой создателей языка Go.

В этой главе мы снимем покров мистики с файловой структуры Go-проектов. Мы разберём, какие фундаментальные проблемы изоляции решает этот шаблон, в каких ситуациях он превращается в неповоротливый бюрократический оверхед, почему компилятор языка Go относится к слову internal как к священному закону и как грамотно адаптировать структуру каталогов под каноны Clean Architecture (14. Clean Architecture и Dependency Rule) и модульного монолита (10. Модульный монолит как компромисс).


Что такое Standard Go Project Layout

Standard Go Project Layout — это неформальный набор соглашений об организации файлов и директорий, популяризированный чрезвычайно известным репозиторием golang-standards/project-layout.

Он возник не на пустом месте: его авторы попытались обобщить и канонизировать паттерны, стихийно сложившиеся в гигантских распределённых проектах первой волны облачной революции (Kubernetes, Prometheus, Docker, Helm).

Несмотря на отсутствие официального статуса со стороны Google, этот шаблон де-факто стал общепринятым языком общения между инженерами. Понимание его структуры обязательно для любого разработчика уровня Middle+ и выше, поскольку более половины промышленных Go-репозиториев в мире построены по этому лекалу.

Ключевые инженерные цели структуры:

  • Осознанное разделение ответственности: жесткое физическое отделение точек входа (main) от библиотек, а закрытого приватного кода сервиса — от публичных интерфейсов.
  • Унификация сборки и CI/CD: предсказуемые пути для Dockerfile, скриптов генерации кода, шаблонов конфигураций и манифестов Kubernetes.
  • Минимизация когнитивной нагрузки при онбординге: любой новый инженер, открыв репозиторий, за 10 секунд понимает, где искать бизнес-логику, а где лежат миграции базы данных.

Обзор ключевых директорий

Классическое дерево крупного промышленного Go-проекта выглядит следующим образом:

DIAGRAM
myapp/
├── cmd/                    # Точки входа (main) для всех бинарников
├── internal/               # Приватный код приложения (инкапсулирован компилятором)
├── pkg/                    # Осознанно публичные библиотеки (для внешнего импорта)
├── api/                    # Контракты API (OpenAPI, Protobuf, gRPC)
├── configs/                # Файлы конфигурации и шаблоны окружения
├── scripts/                # Вспомогательные скрипты (CI/CD, генерация, миграции)
├── build/                  # Упаковка и сборка (Dockerfile, packer)
├── deployments/            # Манифесты развёртывания (k8s, docker-compose, helm)
├── test/                   # Внешние интеграционные/E2E тесты и тестовые фикстуры
├── docs/                   # Архитектурная документация и диаграммы
├── go.mod                  # Определение модуля Go
└── Makefile                # Команды автоматизации разработки

Разберём назначение и внутреннее устройство каждого критически важного каталога:

/cmd

Директория /cmd содержит изолированные подкаталоги для каждого исполняемого бинарника, собираемого из данного репозитория. Имя подкаталога строго определяет имя результирующего исполняемого файла:

DIAGRAM
cmd/
├── server/      # Основной сетевой HTTP/gRPC сервис
│   └── main.go
├── worker/      # Фоновый обработчик очередей и задач
│   └── main.go
└── cli/         # Консольная утилита управления системой
    └── main.go

Золотое правило Кернигана для /cmd: код в пакете main обязан быть предельно лаконичным (thin entry point).
Его единственная обязанность — прочитать флаги и конфигурацию, инициализировать логгер, подключиться к СУБД, собрать граф зависимостей (Dependency Injection) и запустить блокирующий слушатель. Любая бизнес-логика, роутинг или прямые SQL-запросы в файлах cmd/**/main.go — это грубейшая ошибка проектирования.

Go
// cmd/server/main.go
package main

import (
    "log"

    "myapp/internal/config"
    "myapp/internal/handler/http"
    "myapp/internal/repository/postgres"
    "myapp/internal/server"
    "myapp/internal/service/order"
)

func main() {
    // 1. Загрузка конфигурации окружения
    cfg := config.MustLoad()
    logger := log.New(cfg.LogLevel)

    // 2. Инициализация инфраструктуры
    db := postgres.MustConnect(cfg.Database)

    // 3. Сборка слоев приложения
    orderRepo := postgres.NewOrderRepository(db)
    orderService := order.NewService(orderRepo)
    orderHandler := http.NewOrderHandler(orderService)

    // 4. Запуск сервера
    srv := server.New(cfg.HTTP, logger, orderHandler)
    if err := srv.Run(); err != nil {
        logger.Fatal("server failed", "error", err)
    }
}

/internal

/internal — это гранитная стена инкапсуляции и священное сердце любого закрытого приложения.

В отличие от соглашений об именовании в других языках (где папки вроде private — всего лишь рекомендация), в языке Go слово internal зарезервировано на уровне самого инструментария компилятора.

Внутри internal/ код организуют двумя принципиально разными путями:

1. Доменная структура (рекомендуется для DDD и модульного монолита):
Код группируется вокруг бизнес-возможностей (feature-driven). Это минимизирует связанность между несвязанными фичами:

DIAGRAM
internal/
├── order/          # Суверенный модуль заказов
│   ├── handler/
│   ├── service/
│   ├── repository/
│   └── model/
├── user/           # Суверенный модуль пользователей
├── payment/        # Суверенный модуль платежей
└── shared/         # Общий платформенный код приложения
    ├── db/
    └── logger/

2. Слоистая структура (горизонтальные слои):
Применяется в простых CRUD-проектах, но быстро упирается в конфликты зависимостей при разрастании:

DIAGRAM
internal/
├── delivery/       # = handlers (HTTP, gRPC)
├── usecase/        # = business logic
├── repository/     # = data access (PostgreSQL, Redis)
└── entity/         # = domain models

/pkg

Директория /pkg предназначена для выделенных библиотечных модулей, которые совершенно безопасно импортировать сторонним внешним приложениям (например, github.com/yourname/myapp/pkg/something). Помещая пакет в pkg/, автор проекта берёт на себя негласное обязательство: «Этот API стабилен, тщательно документирован и обратно совместим согласно Semantic Versioning».

Типичные обитатели /pkg:

  • Официальный клиент к вашему API: pkg/client/
  • Общеупотребительные структуры системных ошибок и кодов ответа: pkg/apierrors/
  • Самодостаточные инфраструктурные утилиты: pkg/retry/, pkg/pagination/

Если код вашего приложения не предназначен для повторного использования кем-либо за пределами вашего репозитория — директория pkg/ вам вовсе не нужна.

/api

Каталог для хранения контрактов интерфейсов и формальных спецификаций:

  • Спецификации OpenAPI / Swagger (order.yaml) для документирования и валидации REST API.
  • Файлы Protocol Buffers (order.proto по путям вроде api/proto/order/v1/order.proto) для генерации gRPC-клиентов и серверов.
  • Схемы GraphQL (schema.graphql).

Вынесение контрактов в корень репозитория отделяет спецификацию от языка её реализации и упрощает запуск кодогенераторов.

DIAGRAM
api/
├── proto/
│   └── order/
│       └── v1/
│           └── order.proto
├── openapi/
│   └── order.yaml
└── graphql/
    └── schema.graphql

/configs

Каталог для шаблонов и дефолтных настроек сервиса. Сюда помещают файлы вроде config.yaml.example или config.dev.yaml.

Важнейшее правило безопасности: реальные конфигурационные файлы с боевыми паролями, токенами и приватными ключами шифрования никогда не должны попадать в систему контроля версий Git.

/scripts

Склад команд автоматизации: Bash-скрипты миграций, скрипты сборки бинарников под разные архитектуры (Linux AMD64, ARM64), запуск линтеров, а также файл tools.go для фиксации версий зависимостей инструментов кодогенерации (protoc-gen-go, mockgen).

/build

Файлы, отвечающие за контейнеризацию и сборку дистрибутивов: многоэтапные Dockerfile, шаблоны сборки образов Packer (packer.json), скрипты сборки RPM/DEB-пакетов, а также файлы для CI (GitLab CI, GitHub Actions можно положить и сюда, но чаще их держат в корне .github/).

/deployments

Инфраструктурные манифесты, описывающие жизненный цикл сервиса в облаках: конфигурации Kubernetes (Deployments, Services, ConfigMaps, Ingress), манифесты Helm charts, сценарии docker-compose.yaml для локального подъёма зависимостей, модули Terraform.

/test

Каталог для внешних интеграционных сценариев, нагрузочных тестов (k6), сквозных E2E-тестов и каталогов с тестовыми слепками данных (testdata/). Юнит-тесты компонентов (*_test.go) хранятся непосредственно рядом с тестируемыми файлами в internal/.


Ограничения и критика

Несмотря на широкую популярность, шаблон golang-standards неоднократно подвергался жесткой критике со стороны ветеранов индустрии и самой команды разработки языка Go.

Выделим пять главных изъянов слепого следования этому лейауту:

  1. Колоссальный оверхед для малых сервисов:
    Если ваш микросервис состоит из трёх маршрутов и пяти сотен строк кода, создание 15 каталогов ради абстрактного «стандарта» — это чистой воды бюрократия. Для небольших утилит и простых микросервисов плоская структура из пары файлов в корне неизмеримо эффективнее.
  2. Искусственное поощрение слоистой изоляции:
    Каталоги internal/handler, internal/service, internal/repository провоцируют создание микропакетов из одного файла. Это ведёт к искусственным моделям-посредникам, размыванию доменной связности и циклическим импортам.
  3. Путаница с pkg и internal:
    Начинающие разработчики часто не понимают фундаментального предназначения internal/ и превращают pkg/ в склад спагетти.
  4. Отсутствие официального статуса:
    Официальные авторы языка (Роб Пайк, Брэд Фицпатрик, Расс Кокс) неоднократно подчеркивали: они не поддерживают репозиторий project-layout. Знаменитый афоризм Расса Кокса гласит:

    «A little copying is better than a little dependency» (Немного копирования лучше, чем небольшая зависимость).
    Официальная позиция команды Go — начинать проект с минималистичной структуры (main.go) и выделять подпакеты только тогда, когда в этом возникает реальная физическая необходимость.

  5. Проблемы версионирования в монорепозиториях:
    Если вы держите пять разных сервисов в каталоге cmd/ в рамках одного go.mod, изменение любого служебного файла влечет изменение версий для всех бинарников сразу.


Адаптация к современным архитектурным подходам

Как гармонично подружить соглашения Standard Go Project Layout с канонами Clean Architecture и Domain-Driven Design?

Для модульного монолита с DDD:

  • Весь код модулей лежит в internal/{module}/: internal/order/, internal/billing/, internal/catalog/.
  • Внутри каждого модуля допустимо выделять чистые архитектурные слои: domain/ (чистые Entities и Value Objects), application/ (Use Cases), infrastructure/ (SQL-репозитории и адаптеры клиентов), interface/ (handlers).
  • Пакет pkg/ используется только для общих контрактов событий шины или сгенерированных SDK клиентов.

Для микросервисной архитектуры:

  • Если микросервис живёт в собственном Git-репозитории, он образует самостоятельный модуль с корневым go.mod.
  • Вся предметная логика инкапсулируется в internal/, а в каталоге api/ хранятся .proto контракты взаимодействия.

Mechanical Sympathy: влияние на компиляцию и сборку

Как файловая раскладка соотносится с железом и скоростью работы компилятора go build?

TEXT
 [Гигантский плоский package main]
   Изменение 1 строки -> Полная перекомпиляция всего монолита (медленный CPU-прогон)

 [Модульная структура пакетов в internal/]
   Изменение в internal/order -> Компилируется только order ->
   Остальные пакеты берутся из кэша $GOCACHE за миллисекунды!
  • Параллелизм и инкрементальная компиляция:
    Компилятор Go компилирует пакеты параллельно по графу зависимостей. Если код разбит на аккуратные независимые пакеты в internal/, система кэширования ($GOCACHE) перекомпилирует только тот узел, в котором изменились байты. Хранение всего кода в одном пакете main полностью лишает вас этого колоссального преимущества!
  • Стабильность тулинга:
    Единый корневой go.mod и правильное расположение пакетов позволяют штатным инструментам рантайма — таким как go mod vendor и go mod tidy — безупречно разрешать зависимости без коллизий путей.

Альтернативные подходы

В сообществе Go существуют три равноправные школы организации репозиториев:

  1. Плоская структура (Flat Layout):
    Файлы main.go, handlers.go, services.go, repos.go лежат непосредственно в корне репозитория. Это непревзойдённый выбор для микросервисов, прототипов, консольных утилит и сервисов с объёмом кода до 1500–2000 строк.
  2. Domain-driven структура (Package by Feature):
    Пакеты делятся исключительно по бизнес-фичам (order/, user/, billing/), а слои (хэндлер, репозиторий) живут внутри одной директории без избыточных подпакетов.
  3. Фреймворк-ориентированная структура:
    Каркасы вроде Buffalo или генераторы Goa диктуют собственную жесткую структуру каталогов, оптимизированную под их инструменты генерации кода.


Итог

Standard Go Project Layout — это прекрасный навигационный компас для больших систем, но опасный фетиш при слепом копировании. Истинная инженерная зрелость заключается не в том, чтобы разложить 500 строк кода по 20 директориям, а в том, чтобы структура папок отражала реальную архитектурную сложность вашей системы.

Определившись с физическим расположением файлов на диске, мы подошли к следующей фундаментальной проблеме: как элегантно связывать компоненты из разных директорий между собой, не превращая код сборки в кошмар глобальных переменных?

В следующей главе мы разберём проектирование архитектурного связывания: 17. Dependency Injection в Go на уровне архитектуры.