Структура проекта на Go: как не превратить в кашу
Проект на Go начинается с одного main.go, а через полгода превращается в лабиринт из пакетов, где каждый импортирует каждый. Разбираем рабочие приёмы организации кода, сравниваем плоскую структуру, слоёную и подход с domain-driven layout — с конкретными критериями выбора.

Почему структура ломается именно в Go
В Go нет фреймворка, который навязывает каталоги. Нет папки controllers, которую создаёт генератор. Стандартная библиотека не намекает, куда положить бизнес-логику. Это свобода, которая на маленьком проекте экономит время, а на среднем — создаёт проблему: циклические импорты, пакеты-свалки по 40 файлов и ситуацию, когда новый разработчик в команде открывает репозиторий и не понимает, откуда начинать читать.
Привычное решение — скопировать чью-нибудь структуру из статьи на Medium — не работает, потому что структура зависит от размера проекта, количества людей и того, сервис это, CLI-утилита или библиотека. Ниже — три подхода с границами применимости и ценой каждого.
Что нужно до начала
Всё дальнейшее относится к Go 1.21+ с включёнными модулями (go.mod). Если вы работаете с GOPATH-based layout без модулей — сначала мигрируйте, иначе половина рекомендаций не применима.
Ключевое правило Go, которое определяет структуру: пакет — это единица инкапсуляции. Не файл, не класс, а пакет. Всё, что начинается с заглавной буквы, экспортируется на уровне пакета. Циклические импорты между пакетами запрещены компилятором. Из этих двух ограничений вытекает большинство архитектурных решений.
Подход 1: плоская структура
Весь код живёт в корне модуля или в одном-двух пакетах. Типичная картина:
myapp/
main.go
server.go
handler.go
storage.go
model.go
go.mod
Это не «плохо» — это рабочий вариант для проекта, который пишет один-два человека и в котором меньше 5 000 строк кода. Стандартная библиотека Go во многих пакетах устроена именно так: один пакет, много файлов, разделение по файлам — логическое, а не архитектурное.
Что выигрываете
- Нулевой риск циклических импортов — всё в одном пакете.
- Нет необходимости придумывать имена пакетов и решать, «куда это положить».
- Порог входа для нового человека минимален: открыл
main.go, прошёл по вызовам.
Чем платите
- Всё экспортированное видно всему проекту — границы размываются.
- При росте до 30+ файлов навигация ломается:
handler.goпревращается вhandler_user.go,handler_order.go,handler_order_v2.go. - Тесты начинают тянуть зависимости, которые к тестируемой функции отношения не имеют.
Подход 2: слоёная структура
Классический «луковый» или «гексагональный» вариант, адаптированный под Go:
myapp/
cmd/
myapp/
main.go # точка входа, собирает зависимости
internal/
handler/ # HTTP-обработчики
service/ # бизнес-логика
storage/ # доступ к данным
model/ # структуры и доменные типы
pkg/ # код, который можно переиспользовать
go.mod
Каталог internal — стандартный механизм Go: код внутри него не импортируется из других модулей. Это не конвенция, а проверка компилятора.
Пакет в Go должен предоставлять что-то, а не содержать что-то. Если вы не можете описать пакет одним предложением без слова «и» — разбивайте.
Что выигрываете
- Понятный граф зависимостей: handler → service → storage, обратного пути нет.
- Слой
serviceтестируется без HTTP и без базы — через интерфейсы к storage. - Новый разработчик видит архитектуру уже в дереве каталогов.
Чем платите
- Пакет
modelстановится «помойкой»: все структуры ложатся туда, потому что их импортируют все слои. Через год в нём 60 типов, которые друг с другом не связаны. - Появляется соблазн создать пакет
utilsилиhelpers— верный признак, что граница проведена неправильно. - Для маленького проекта это оверинжиниринг: три каталога, в каждом по одному файлу.
Сноска: cmd/ — конвенция, описанная в разделе Go Module Layout официальной документации. Она позволяет держать несколько точек входа в одном репозитории: cmd/server/, cmd/migrate/, cmd/worker/.
Подход 3: domain-driven layout
Пакеты организованы не по техническому слою, а по предметной области:
myapp/
cmd/
myapp/
main.go
internal/
order/
order.go # тип Order, интерфейс Repository
service.go # бизнес-логика заказов
handler.go # HTTP-обработчики заказов
postgres.go # реализация Repository для PostgreSQL
user/
user.go
service.go
handler.go
postgres.go
platform/
postgres/ # общее подключение к базе
httpserver/ # обёртка над сервером
go.mod
Каждый доменный пакет содержит свои типы, интерфейсы, логику и реализации. Общая инфраструктура выносится в platform (или infra, название не принципиально).
Что выигрываете
- Нет пакета-свалки
model: типOrderживёт в пакетеorder, типUser— вuser. - Изменение в одном домене не затрагивает файлы в другом — удобно при code review и при работе нескольких команд.
- Легче выделить домен в отдельный сервис, если дойдёт до этого.
Чем платите
- Сквозные задачи (авторизация, логирование, трейсинг) не имеют «своего» домена — приходится решать, куда их класть.
- Связь между доменами порождает интерфейсы-переходники:
order.UserGetter, который дублирует частьuser.Service. Это не баг, а сознательный trade-off: вы платите дублированием интерфейса за отсутствие циклического импорта. - В проекте с одним доменом подход вырождается в слоёную структуру внутри одного пакета — смысла нет.
Сравнение подходов
| Критерий | Плоская | Слоёная | Domain-driven |
|---|---|---|---|
| Размер проекта | до 5 000 строк | 5 000 — 50 000 строк | от 20 000 строк |
| Команда | 1–2 человека | 2–5 человек | 3+ человек или несколько команд |
| Риск циклических импортов | нет | низкий | средний (между доменами) |
| Тестирование без инфраструктуры | сложно | удобно (мокаете storage) | удобно (мокаете интерфейсы внутри домена) |
| Навигация для новичка | просто, пока мало файлов | понятна по слоям | понятна по доменам |
| Оверинжиниринг на малом проекте | нет | заметный | сильный |
Типичные ошибки, которые ломают любую структуру
Пакет utils
Пакет без определённой ответственности. Если в нём функция FormatDate и функция HashPassword — это два разных пакета или часть тех пакетов, которые их используют. В Go принято определять функцию рядом с вызывающим кодом, а не в общем месте «на всякий случай».
Интерфейсы «впрок»
Go-интерфейсы принято объявлять на стороне потребителя, а не реализатора. Пакет service объявляет интерфейс OrderStore с теми двумя методами, которые ему нужны, — а не пакет storage экспортирует интерфейс из двадцати методов, большинство которых никто не вызывает.
Пакет ради одного типа
Если пакет содержит один файл с одной структурой — это, скорее всего, не пакет, а тип, который должен жить в соседнем пакете. Пакеты в Go дешёвые, но не бесплатные: каждый — это имя в import path, точка принятия решений при рефакторинге и строка в go.sum при тестировании.
Циклический импорт через «общий» пакет
Два пакета зависят друг от друга — вы выносите общий тип в третий пакет. Через месяц третий пакет зависит от четвёртого, четвёртый — от первого. Решение: определите интерфейс на стороне потребителя и передавайте зависимость через конструктор (dependency injection без фреймворков).
Когда что выбирать
- CLI-утилита, лямбда, прототип — плоская структура. Переключайтесь на слоёную, когда начинаете дублировать код между хендлерами или когда тесты тянут реальную базу.
- Типичный API-сервис с одной базой и 5–15 эндпоинтами — слоёная. Переключайтесь на domain-driven, когда пакет
modelперестаёт помещаться на один экран, а два разработчика регулярно правят один файл. - Платформа с несколькими доменами, разными хранилищами, командой 5+ — domain-driven. Здесь стоимость начальной настройки окупается предсказуемостью при росте.
Что делать дальше
Определите, какой из трёх подходов соответствует текущему размеру вашего проекта, и проведите рефакторинг за один проход — частичная миграция структуры хуже любой из трёх. После этого:
- Запустите
go vet ./...и убедитесь, что циклических импортов нет. - Проверьте, что ни один пакет не называется
utils,helpers,commonилиmisc. - Убедитесь, что каждый пакет описывается одним предложением — напишите это предложение в
doc.go. - Настройте линтер
depguard(в составеgolangci-lint), чтобы запретить импортinternal/orderизinternal/user, если вы выбрали domain-driven layout и хотите сохранить границы.
Организация wire-инъекций, middleware-цепочек и конфигурации — смежные темы, которые заслуживают отдельного разбора и сюда не вмещаются.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Go — Organizing a Go module (официальная документация), Go Blog — Package names
Частые вопросы
Обязательно ли использовать каталог cmd/?
Что делать, если два пакета нуждаются друг в друге и возникает циклический импорт?
Стоит ли использовать golang-standards/project-layout как эталон?
Нужен ли пакет pkg/ в проекте?
Как понять, что пора менять структуру проекта?
А если у меня Go 1.18 или старше — эти подходы работают?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.