Перейти к содержимому

Project Layout в Go

Зачем знать: В Go нет единого «официального» layout — но есть устоявшиеся практики. От правильной структуры зависит, сможет ли команда масштабироваться, найдёт ли новичок код за 5 минут или будет ползать по 50 пакетам. На middle-уровне ждут понимания, ЧТО, ЗАЧЕМ и КОГДА.

  1. Базовая концепция
  2. В Go идиоматично
  3. Gotchas
  4. Best practices
  5. Вопросы на собесе
  6. Practice
  7. Источники

Команда Go никогда не публиковала «официальный» layout. Это вызывает споры. Самое цитируемое:

Каждый из этих подходов работает. Главное — внутренняя консистентность в проекте.

Самая известная структура:

my-project/
├── cmd/ # main-пакеты приложений
│ └── api/
│ └── main.go
├── internal/ # приватный код (compiler-enforced)
│ ├── app/
│ ├── pkg/
│ └── ...
├── pkg/ # публичные библиотеки (если есть)
├── api/ # OpenAPI/proto/gRPC файлы
├── web/ # static, шаблоны
├── configs/ # конфиги
├── deployments/ # Dockerfile, k8s manifests
├── scripts/ # bash-утилиты
├── test/ # интеграционные тесты, fixtures
├── docs/ # документация
├── examples/ # примеры использования (для библиотек)
├── tools/ # tools.go (build tools)
├── vendor/ # vendored deps (опционально)
├── go.mod
├── go.sum
└── Makefile

Критика: многие считают, что это «cargo cult» — копирование без понимания. Russ Cox (один из авторов Go) писал что это «not an official standard». Команды используют это как отправную точку, упрощая.

Это единственный path, который Go-компилятор обрабатывает специально.

github.com/foo/bar/
├── internal/secret/
│ └── magic.go // package secret
└── public/
└── public.go // package public, может импортить internal/secret
github.com/baz/qux/
└── main.go // НЕ может импортить github.com/foo/bar/internal/secret
// compile error: use of internal package not allowed

Правило: пакет в <repo>/internal/X доступен только в <repo>/.... Если репозиторий ниже корня — <root>/internal/X доступен только в поддереве <root>/....

Используется как enforce приватности: если ты пишешь сервис — клади ВСЁ в internal/. Тогда никто извне не зацепится за твою внутреннюю структуру.

Каждый main-пакет — в своей подпапке внутри cmd/. Имя папки = имя binary.

cmd/
├── api/main.go // go build -o api ./cmd/api
├── worker/main.go // go build -o worker ./cmd/worker
└── migrator/main.go // go build -o migrator ./cmd/migrator

main.go — тонкий, только composition root. Никакой бизнес-логики.

Идея: всё, что должно быть импортируемо снаружи, кладётся в pkg/. Остальное — в internal/.

Критика pkg/:

  • Если репозиторий — приложение (не библиотека), pkg/ не нужен вообще.
  • Большинство публичных пакетов кладутся в корень репо (как pkg/errors от Dave Cheney или pgx).
  • Это лишний уровень вложенности.

Rakyll и многие другие советуют не использовать pkg/ в простых проектах.

Подход Ardan Labs:

service/
├── app/ // application layer
│ ├── services/
│ │ └── sales-api/ // одно приложение
│ │ └── main.go
│ └── tooling/
│ └── admin/
├── business/ // business layer
│ ├── core/ // domain types (entity, aggregate)
│ │ ├── order/
│ │ ├── user/
│ │ └── product/
│ ├── data/ // persistence
│ │ └── dbschema/
│ └── web/ // веб-логика (auth, errors, validation)
├── foundation/ // re-usable infrastructure
│ ├── docker/
│ ├── logger/
│ └── web/
├── vendor/
└── go.mod

Идея: 3 слоя — application, business, foundation. foundation — низкоуровневое (logger, http kit), business — домен, application — entry point.

Для команд, делающих Clean Arch:

service/
├── cmd/api/main.go
├── internal/
│ ├── domain/ // entities
│ ├── ports/ // interfaces (in/out)
│ ├── adapters/
│ │ ├── primary/ // HTTP/gRPC handlers
│ │ └── secondary/ // DB, Kafka
│ └── application/ // use cases
└── go.mod

Для маленького микросервиса:

service/
├── main.go
├── server.go
├── routes.go
├── handlers.go
├── auth.go
├── db.go
├── tests/
└── go.mod

Один пакет, плоская структура. Хорошо работает, пока сервис маленький.

Mono-repoPoly-repo
Один репо со всеми сервисамикаждый сервис — свой репо
Prosshared code, atomic changes, проще CIавтономия команд, фокус
Consбольшие репо, сложный CIдублирование, версионирование сложно
ToolsBazel, go workspacesgo modules

Для Go в mono-repo популярны:

  • go workspaces (1.18+) — несколько модулей в одном репо;
  • Bazel — для крупных компаний (Google, Uber);
  • Один go.mod в корне + internal/ для модулей.

go.work — workspace file, объединяет несколько модулей для локальной разработки.

my-monorepo/
├── go.work
├── services/
│ ├── orders/
│ │ ├── go.mod // module github.com/co/orders
│ │ └── main.go
│ └── payments/
│ ├── go.mod // module github.com/co/payments
│ └── main.go
└── pkg/
└── logger/
├── go.mod // module github.com/co/logger
└── logger.go

go.work:

go 1.22
use (
./services/orders
./services/payments
./pkg/logger
)

Преимущества:

  • Локальные изменения в logger сразу видны в orders/payments, без replace-директив.
  • Каждый модуль независимо релизится.
  • go.work не коммитят (в .gitignore) — это локальный артефакт. Хотя некоторые команды коммитят для unified dev experience.

Правила (из effective Go):

  • shorttime, http, log, os;
  • lowercasebytes, не Bytes;
  • no underscoreshttputil, не http_util;
  • no camelCasehttputil, не httpUtil;
  • singularuser, не users;
  • descriptiveorder, не o.

Избегайте generic-имён:

  • util, common, helper, base, lib, tools — ничего не сообщают.
  • httputil, slicesx, idgen, pricing.
// ПЛОХО:
// package util
// func Pluralize(s string) string {...}
util.Pluralize("user")
// ХОРОШО:
// package stringx
// func Pluralize(s string) string {...}
stringx.Pluralize("user")

Пакет — одна цель. Если в пакете 50 несвязанных функций — разделите.

// ПЛОХО: package util
util.FormatTime(...)
util.ParseJSON(...)
util.HashPassword(...)
util.GenerateUUID(...)
// ХОРОШО:
timeutil.Format(...)
jsonutil.Parse(...)
crypto.HashPassword(...)
uuid.New()

Экспортируйте только то, что нужно. Чем меньше публичных типов, тем легче эволюция.

// ПЛОХО: всё public
type Server struct {
Logger *slog.Logger
DB *sql.DB
Cache *redis.Client
Internal internalState
}
// ХОРОШО: public — только то, что нужно вызывать
type Server struct {
logger *slog.Logger
db *sql.DB
cache *redis.Client
}
func (s *Server) Run() error { ... }
func (s *Server) Shutdown(ctx context.Context) error { ... }

Go запрещает циклические импорты (compile error). Если package a импортирует package b, b НЕ может импортировать a.

Типичные решения:

  • Выделить общий интерфейс/тип в отдельный пакет (api, model).
  • Использовать функциональные параметры/колбэки.
  • Объединить пакеты, если они слишком связаны.
// Цикл:
// package order: import "user"
// package user: import "order"
// Решение 1: общий пакет
// package model: type UserID, type OrderID
// package order, user — import model
// Решение 2: интерфейсы
// package order: type UserService interface { GetByID(...) }
// package user реализует UserService, но не импортит order

Тесты лежат рядом с кодом:

package order/
├── order.go
├── order_test.go // package order (white-box)
└── order_external_test.go // package order_test (black-box)
  • package order в _test.go — видит приватные функции (white-box).
  • package order_test — только public API (black-box). Полезно для библиотек.
order.go
package order
type Order struct { ... }
func (o *Order) calculate() int { ... }
// order_test.go: white-box
package order
import "testing"
func TestCalculate(t *testing.T) {
o := &Order{}
o.calculate() // видит приватный
}
// order_external_test.go: black-box
package order_test
import (
"testing"
"myrepo/order"
)
func TestCalculate(t *testing.T) {
o := order.New()
o.Total() // только public API
}

Папка testdata/ — особая. Go-tools игнорируют её при сборке.

order/
├── order.go
├── order_test.go
└── testdata/
├── valid_order.json
└── invalid_order.json
func TestParseOrder(t *testing.T) {
data, _ := os.ReadFile("testdata/valid_order.json")
o, err := ParseOrder(data)
// ...
}

vendor/ — папка с локальной копией зависимостей. До go modules это был основной механизм.

Когда нужен:

  • Offline builds (CI без интернета);
  • Воспроизводимость (хотя go.sum + module proxy решают это);
  • Аудит зависимостей (можно посмотреть исходники);
  • Регуляторика в банковской сфере.

Команды:

Окно терминала
go mod vendor # создать vendor/
go build # использует vendor/, если есть
go build -mod=mod # игнорирует vendor/

Современные best practices: не vendor, если нет специальной причины.

go.mod
module github.com/myorg/myservice
go 1.22
require (
github.com/jackc/pgx/v5 v5.5.0
github.com/google/uuid v1.5.0
)
require (
// indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
)

Один go.mod на репо (если не workspace). Имя модуля = import path. Принято — github.com/<org>/<repo>.

order-service/
├── cmd/
│ └── api/main.go
├── internal/
│ ├── domain/ // entities
│ │ ├── order.go
│ │ └── money.go
│ ├── usecase/ // use cases
│ │ ├── create_order.go
│ │ ├── create_order_test.go
│ │ └── ports.go // interfaces
│ ├── adapter/
│ │ ├── postgres/
│ │ │ ├── order_repo.go
│ │ │ └── order_repo_test.go
│ │ ├── kafka/
│ │ │ └── publisher.go
│ │ └── http/
│ │ ├── order_handler.go
│ │ └── middleware.go
│ ├── config/
│ │ └── config.go
│ └── app/
│ └── app.go // composition root
├── api/
│ └── openapi.yaml
├── migrations/
│ ├── 001_create_orders.up.sql
│ └── 001_create_orders.down.sql
├── deployments/
│ ├── Dockerfile
│ └── k8s/
├── scripts/
├── Makefile
├── go.mod
└── README.md
mycli/
├── main.go // если binary — единственный
├── cmd/ // если несколько subcommands (cobra)
│ ├── root.go
│ ├── create.go
│ └── list.go
├── internal/
│ ├── service/
│ └── client/
├── go.mod
└── README.md
mylib/
├── doc.go // package-level doc
├── mylib.go // основные типы и API
├── client.go
├── options.go
├── errors.go
├── mylib_test.go
├── examples/
│ ├── basic/
│ │ └── main.go
│ └── advanced/
│ └── main.go
├── internal/ // приватные хелперы
│ └── proto/
├── go.mod
├── LICENSE
└── README.md

Заметьте: нет cmd/, internal/X/Y/Z, pkg/ — это лишняя вложенность для библиотеки.

Для CI-инструментов, которые должны быть зафиксированы в go.sum:

//go:build tools
package tools
import (
_ "github.com/golang-migrate/migrate/v4/cmd/migrate"
_ "github.com/swaggo/swag/cmd/swag"
_ "github.com/golangci/golangci-lint/cmd/golangci-lint"
)

go.sum фиксирует версии, в CI:

Окно терминала
go install github.com/golang-migrate/migrate/v4/cmd/migrate

В Go 1.24+ — go.mod tool directive (новее, рекомендуется).

Стандарт для Go-проектов:

.PHONY: build test lint run docker
build:
go build -o bin/api ./cmd/api
test:
go test ./... -race -cover
lint:
golangci-lint run
run:
go run ./cmd/api
docker:
docker build -t myorg/api:$(shell git rev-parse --short HEAD) .
migrate-up:
migrate -database "$(DATABASE_URL)" -path migrations up
generate:
go generate ./...

Многие новички копируют pkg/ из golang-standards. В реальности, если проект — приложение, pkg/ бесполезен. Используйте internal/.

// ПЛОХО:
internal/services/order/usecase/createOrder/handler/... // 6 уровней

Не делайте глубже 3-4 уровней без причины.

// ПЛОХО:
import "myrepo/util"
util.Format(...)
util.Parse(...)
// Что делает util? Никто не знает.

Лечите конкретными именами: timeutil, httputil, slicesx.

package a:
import "b"
type A struct{}
package b:
import "a" // CYCLE!

Compile error: import cycle not allowed. Решение — общий пакет или интерфейсы.

// в репо github.com/foo/bar:
import "github.com/baz/qux/internal/secret"
// compile error: use of internal package not allowed

Это by design. Если действительно нужно — копируй код (с указанием license).

// package myservice (1000 файлов, 50000 строк)

Это monolith внутри Go. Сложно навигировать, легко плодить циклы внутри пакета. Делите по доменам.

internal/
├── interfaces/
├── types/
├── consts/
├── helpers/
├── models/
└── services/

Это Java-style, не Go. В Go типы и интерфейсы живут с кодом, который их использует, а не отдельно.

Иногда новички пытаются сделать так:

// package a
import "b"
type A struct { b *B.B }
// package b
type Doer interface { Do() }
// Doer реализован в a, а b его использует

Это работает (нет цикла, потому что b ничего не импортит из a). Это стандартная техника: интерфейс там, где он используется.

// ПЛОХО: cmd/api/main.go — 500 строк бизнес-логики

main.go — тонкий wiring. Логика — в internal/. Cmd-папка должна содержать ровно столько кода, чтобы запустить приложение.

vendor/ дублирует deps, увеличивает репо, требует ручной синхронизации. Не используйте, если нет конкретной причины (offline builds, аудит, регуляторика).

order/
├── order.go
└── testdata.go // ❌ не testdata

testdata — папка, не файл. Go tools игнорируют именно папку.

// ПЛОХО:
project/
├── order/
│ └── order.go
└── tests/
└── order_test.go // ❌ не находится рядом с кодом

В Go тесты — рядом с кодом, в том же пакете. tests/ — только для интеграционных или E2E, не unit.

Иногда видишь internal/pkg/X — это смесь двух подходов. Достаточно internal/X.

Точка спора. Аргументы:

  • Коммитить: unified dev experience, не нужно каждому настраивать локально.
  • Не коммитить: workspace может конфликтовать с CI (где multi-module билдится по-другому).

Многие команды используют go.work.example в репо, реальный go.work — в .gitignore.


  1. Старт — flat layout. Усложняйте по мере роста.
  2. internal/ для приватной логики. Compiler enforce, защита от внешних пользователей.
  3. cmd/<name>/main.go для каждого binary. Имя папки = имя binary.
  4. pkg/ НЕ используйте, если проект — приложение, а не библиотека.
  5. Имена пакетов short, lowercase, singular. Без util/common/helper.
  6. Один пакет — одна цель (high cohesion).
  7. Минимальный экспорт. Сначала private, экспорт — по необходимости.
  8. Интерфейс там, где используется. Не в реализации.
  9. Тесты рядом с кодом. *_test.go в том же пакете.
  10. testdata/ для фикстур. Go tools игнорируют.
  11. Migrations в migrations/ в корне.
  12. API spec (OpenAPI, .proto) в api/.
  13. Deployment artifacts в deployments/ (Dockerfile, k8s).
  14. Makefile — стандарт для build/test/lint/run.
  15. Не плодите вложенность — 3-4 уровня max без сильной причины.
  16. Композицию делать в main.go (или internal/app/).
  17. go.work — для workspace локально, обычно .gitignore.
  18. Documentation в doc.go для package-level doc (виден в pkg.go.dev).

  1. Зачем нужна папка internal/ в Go? Это keyword компилятора: пакеты в <repo>/internal/ нельзя импортить за пределами <repo>. Используется для приватной бизнес-логики.

  2. Чем pkg/ отличается от internal/? pkg/ — публичные пакеты, доступны откуда угодно. internal/ — приватные, compile-enforced. Многие советуют не использовать pkg/ в простых проектах.

  3. Что положить в cmd/? Подпапку на каждый main-binary. cmd/api/main.go, cmd/worker/main.go. Имя папки = имя binary.

  4. Зачем разделять cmd/api и cmd/worker? Чтобы был отдельный binary на роль. Деплоятся отдельно, разная конфигурация, разные ресурсы.

  5. Почему util, common, helper — плохие имена пакетов? Не сообщают, что внутри. Превращаются в свалку. Используйте конкретные: httputil, slicesx.

  6. Что такое testdata/? Папка с фикстурами для тестов. Go-tools (go build, go test) игнорируют её при поиске исходников.

  7. White-box vs black-box тесты? White-box: package order в _test.go, видит приватные. Black-box: package order_test, только public API.

  8. Можно ли импортить pkg-x/internal/y из pkg-y? Нет, если pkg-y не в поддереве pkg-x. Compile error.

  9. Что такое go.work и когда нужен? Workspace file (Go 1.18+) для нескольких модулей в одном репо без replace-директив. Удобно для локальной разработки monorepo.

  10. Чем mono-repo отличается от poly-repo? Mono — один репо для всех сервисов, poly — каждый сервис свой репо. Mono упрощает shared code и atomic changes, poly даёт автономию.

  11. Куда положить миграции БД? В корне migrations/, отдельная папка. Запускаются отдельным инструментом (golang-migrate, goose, atlas).

  12. Куда .proto/.openapi.yaml? В api/ в корне репо. Документация контракта.

  13. Что такое vendor/? Папка с локальной копией зависимостей. Используется для offline builds, аудита, регуляторики. Создаётся go mod vendor.

  14. Где должен жить Dockerfile? В корне (если один) или в deployments/ (если несколько image). Многие команды кладут в корень для простоты.

  15. Можно ли в Go циклически импортить пакеты? Нет, compile error: import cycle not allowed. Лечится разделением, общим пакетом или интерфейсом.

  16. Зачем разделять test fixtures и unit-тесты? testdata/ — данные для тестов. Тесты — рядом с кодом, в том же пакете.

  17. Что такое doc.go? Файл с package-level документацией. Содержит только // package doc комментарий и package X declaration. Виден на pkg.go.dev.

  18. Куда положить общую логику логирования? В отдельный пакет, например internal/logger/ или pkg/logger/ (если переиспользуется между сервисами).

  19. Когда оправдан pkg/? Если репозиторий — библиотека, или содержит публичные SDK для других команд.

  20. Почему main.go должен быть тонким? Чтобы бизнес-логика была testable и переиспользуема. main.go — только wiring, остальное в internal/.


  1. Возьмите свой текущий проект и проверьте: есть ли internal/? Есть ли pkg/? Нужны ли они на самом деле? Перенесите код, если что-то не так.

  2. Создайте микросервис со структурой:

    • cmd/api/main.go
    • internal/domain/, internal/usecase/, internal/adapter/postgres/, internal/adapter/http/
    • api/openapi.yaml
    • migrations/
    • Makefile с build, test, lint, run.
  3. Сделайте mono-repo на go workspaces:

    • 2 микросервиса (orders, payments);
    • 1 shared пакет (logger);
    • go.work объединяет их;
    • покажите, что изменение в logger мгновенно видно в обоих сервисах.
  4. Найдите в каком-нибудь популярном репо (например, kubernetes/kubernetes, cockroachdb/cockroach) — как они структурированы. Сравните с golang-standards.

  5. Напишите tools.go с зависимостями: golang-migrate, swag, golangci-lint. Зафиксируйте в go.sum.