30 апр. 2026 г.·7 мин чтения

Структура пакетов Go-сервиса для репозиториев больше одной папки

Структура пакетов Go-сервиса, которая разделяет transport, domain и storage простым способом, чтобы растущий репозиторий оставался понятным и удобным для изменений.

Структура пакетов Go-сервиса для репозиториев больше одной папки

Почему одна папка начинает мешать

Поначалу Go-сервис в одной папке кажется удобным. Можно открыть репозиторий, пробежаться по нескольким файлам и понять всё за один проход. Но это работает только до тех пор, пока HTTP-handlers, бизнес-правила, SQL-запросы и вспомогательный код не начинают жить рядом друг с другом.

Потом границы размываются. Один handler валидирует входные данные, собирает SQL, применяет правило ценообразования, пишет ошибку в лог и в этом же файле формирует ответ. Вроде бы ничего явно плохого, но каждый файл начинает делать слишком много. Со временем уже неясно, относится ли изменение к API-слою, domain-логике или database-коду.

Небольшие правки тоже расползаются шире, чем должны. Допустим, вы меняете способ расчёта итоговой суммы заказа. В тесной структуре это может затронуть request-структуру, тест handler'а, SQL-scan и JSON-форму ответа, хотя реальное изменение касается только одного бизнес-правила. Проблема не только в лишнем наборе текста. Код становится сложнее проверять, потому что в одном diff'е смешаны несвязанные детали.

С тестами дальше обычно тоже становится хуже. Если логика проверяется только через HTTP-endpoint, который завязан на реальную базу данных, даже простые проверки становятся тяжёлыми. Правило вроде «отклонять заказ без товаров» должно покрываться маленьким unit-тестом. В смешанной папке оно часто превращается в тест, который поднимает router, создаёт фикстуры и ждёт состояния в базе.

Новым коллегам это мешает сильнее всего. Они открывают репозиторий и видят решения, разбросанные по файлам с названиями, которые понятны только тому, кто их писал. Где живёт проверка склада? Почему один SQL-запрос находится в handler'е, а другой — в repository-файле? Какую структуру можно безопасно переиспользовать, а какая существует только для JSON?

Маленькому репозиторию не нужен лишний церемониал. Но ему нужны понятные места для разных видов кода. Когда у каждого решения появляется свой дом, изменения становятся меньше, тесты — быстрее, а код перестаёт ощущаться скользким.

За что должен отвечать каждый слой

Когда Go-сервис растёт, проблемы обычно начинаются в тот момент, когда один пакет одновременно обрабатывает HTTP, бизнес-правила и database-запросы. Ошибки становится сложнее отследить, потому что каждый файл может затронуть любую часть системы. Более аккуратная структура пакетов Go-сервиса даёт каждой части одну задачу.

Transport должен работать с внешним миром. Он читает HTTP-запрос, парсит JSON, проверяет простые ошибки во входных данных и превращает результаты domain-логики в ответ. Он может знать о статус-кодах, заголовках и контексте запроса. Но он не должен решать правила ценообразования, правила доступа или детали работы с базой.

Domain должен решать, что делает сервис. Именно сюда стоит помещать правила вроде «заказу нужен хотя бы один товар» или «отменённый заказ нельзя отправить». Domain принимает понятные входные данные, при необходимости обращается к storage за данными и возвращает понятный результат. Ему не нужно знать, пришёл ли запрос из HTTP, gRPC или фоновой задачи.

Storage должен читать и записывать данные. Он знает SQL, названия таблиц, индексы и детали транзакций. Но он не должен формировать API-ответы или навязывать поведение, зависящее от конкретного запроса. Если позже вы замените PostgreSQL на что-то другое, большая часть изменений должна остаться внутри storage.

Простой обмен выглядит так:

  • Transport парсит запрос в небольшую input-структуру.
  • Domain проверяет правила и решает, что делать дальше.
  • Storage сохраняет или загружает данные для domain.
  • Transport превращает результат в JSON и статус-коды.

Держите общие типы маленькими и очевидными. Если тип пересекает границы пакетов, у него должна быть одна ясная задача. Например, CreateOrderInput — это нормально. А вот большая структура, которая одновременно содержит JSON-теги, SQL-поля и бизнес-флаги, — источник путаницы.

Направление вызовов должно оставаться предсказуемым: transport -> domain -> storage. Пусть верхний слой вызывает нижний. Не давайте storage импортировать типы transport, и не заставляйте domain возвращать сырые database-строки. Если ваш order handler может вызвать domain с обычной структурой, а domain — запросить у storage ровно то, что ему нужно, репозиторий остаётся простым в изменении и не превращается в набор ритуалов.

Структура, которая остаётся небольшой

Хорошая структура пакетов Go-сервиса держит одну идею в одном месте. Как только handlers начинают вызывать SQL, а бизнес-правила просачиваются в случайные helpers, даже небольшой репозиторий становится неудобным для изменений. Для этого не нужен большой framework.

.
├── cmd/
│   └── api/
│       └── main.go
└── internal/
    ├── app/
    │   ├── config.go
    │   └── startup.go
    ├── transport/
    │   └── http/
    │       ├── order_handler.go
    │       └── order_dto.go
    ├── domain/
    │   ├── order.go
    │   └── order_service.go
    └── storage/
        └── postgres/
            ├── order_repo.go
            └── order_row.go

cmd/api запускает сервер и соединяет всё вместе. Здесь стоит открыть базу данных, собрать router, создать services и вызвать ListenAndServe. Если main.go начинает разрастаться больше чем на один-два экрана, вынесите загрузку конфигурации и helpers запуска в internal/app, чтобы код старта оставался легко читаемым.

internal/transport/http — это край системы. Он должен парсить запросы, вызывать domain-слой и превращать результат в HTTP-ответы. Храните здесь HTTP-детали: статус-коды, JSON-формы, query-параметры, заголовки. Не позволяйте этому пакету собирать SQL или решать бизнес-правила.

internal/domain хранит use case'ы. Здесь заказ могут отклонить, пользователя — заблокировать, а скидка — истечь. Domain-слой должен работать с обычными Go-типами и понятными интерфейсами. Он не должен знать ничего о http.Request или строках Postgres.

internal/storage/postgres делает одну задачу: общается с Postgres. Сюда кладите запросы, scans и преобразование строк в domain-типы. Если схема базы изменится, большая часть этой суеты должна остаться внутри этого пакета.

Такая структура остаётся небольшой, потому что у каждого пакета узкая роль, но мест для поиска всё равно немного. Для многих сервисов этого достаточно. Если для новой функции вам нужно три дополнительных пакета ещё до того, как вы напишете саму бизнес-правилo, значит, репозиторий уходит в лишний церемониал.

Как должны двигаться данные между слоями

Хорошая структура пакетов Go-сервиса держит изменение формы данных на краях, а не в середине. Transport должен работать с JSON, gRPC-сообщениями, заголовками и статус-кодами. Domain-код должен работать с простыми Go-структурами, которые описывают действие, которое вы хотите выполнить.

Если HTTP-handler получает POST /orders, пусть он декодирует тело запроса в transport-тип, а затем преобразует его во что-то простое вроде CreateOrderInput. Этот input идёт в domain-пакет. Domain-код не должен знать, пришёл ли вызов из REST, gRPC, CLI-команды или очереди.

То же правило работает и в обратную сторону. Domain запрашивает у storage данные в понятных domain-типах, а не в сырых database-строках. Storage может читать SQL-строки, сканировать столбцы и превращать их в типы, которые имеют смысл для сервиса, например Order, Customer или InventoryItem.

Так каждый пакет остаётся небольшим и честным. Transport говорит на языке сети. Storage — на языке базы данных. Domain — на языке бизнес-правил.

Простой заказ хорошо это показывает. Handler декодирует JSON вроде {"customer_id": "42", "items": [...]} и превращает его в CreateOrderInput. Domain проверяет, существует ли customer, есть ли товары в наличии и какой должна быть итоговая сумма. Затем он вызывает методы storage, например FindCustomer, ReserveItems и SaveOrder.

Ошибки тоже должны менять форму только на границе. Storage может возвращать низкоуровневые ошибки вроде дубликатов записей или отсутствующих строк. Domain может превращать их в бизнес-ошибки вроде ErrCustomerNotFound или ErrOutOfStock. Потом transport сопоставляет их с ответом, который увидит клиент, например 404, 409 или gRPC status code.

Если типы одного слоя начинают просачиваться в другой, код быстро становится липким. Handler, который передаёт database-модель прямо в domain-логику, может казаться быстрым в первый день, но обычно делает каждый последующий change сложнее. Лучше держать преобразования простыми и локальными — тогда репозиторий легко растёт.

Переходите к этому по одной функции

Проверьте структуру на прочность
Проверьте, убирает ли дерево пакетов путаницу или просто добавляет церемоний.

Хорошая структура пакетов Go-сервиса редко начинается с полного переписывания. Начните с endpoint'а, который меняется чаще всего, потому что именно этот код уже забирает больше всего времени. Если один handler парсит JSON, проверяет бизнес-правила, пишет SQL и собирает response, это лучшее место для первого разделения.

Выберите один путь через приложение и оставьте остальное как есть. Если POST /coupons/apply меняется каждый спринт, приводите в порядок только этот поток. Вам нужно одно небольшое улучшение, которое выйдет на этой неделе, а не переписывание всего репозитория, из-за которого работа встанет.

Хорошо работает такой порядок:

  • Оставьте HTTP-детали в handler'е: парсинг запроса, статус-коды и JSON-ответ.
  • Перенесите принятие решений в domain-service, например проверку, можно ли применить купон.
  • Вынесите прямую работу с базой в storage-методы, даже если у этого пакета пока только один файл.
  • Запускайте тесты после каждого перемещения, а затем фиксируйте изменение, пока его легко проверять.

В итоге handler должен стать тонким и скучным. И это хорошо. Handler, который читает input, вызывает один метод сервиса и переводит результат в response, намного проще доверить, чем функции на 200 строк, полной условий и SQL-строк.

Не пытайтесь заранее продумать каждый пакет. В небольшом service-репозитории лишний церемониал распространяется быстро. Вам не нужны дополнительные interfaces, generic-обёртки или полное дерево папок для функций, которые всё ещё живут в одном файле.

Сначала перенесите одно правило. Потом один запрос. Потом снова запустите тесты. Если что-то сломается, вы сразу поймёте, на каком шаге это произошло.

Обычно именно так и растёт практичная структура проекта Go, не превращаясь в package theater. Репозиторий остаётся понятным, у каждого пакета есть своя роль, а команда может продолжать выпускать изменения, пока код становится проще.

Простой пример: создание заказа

Запрос на создание заказа хорошо показывает, зачем нужны границы пакетов. В удачной структуре пакетов Go-сервиса handler работает с wire-format, domain решает, можно ли принять заказ, а storage сохраняет результат.

Задача handler'а невелика. Он читает JSON, проверяет, что customer ID указан, убеждается, что есть хотя бы один item, и отклоняет битый input, например отсутствие product ID или quantity, равное нулю. Если тело запроса сломано, на этом всё и останавливается. Handler не должен проверять остатки на складе или решать, может ли заблокированный customer покупать.

Этими правилами владеет domain-service. Он загружает состояние customer, проверяет, может ли аккаунт размещать заказы, сверяет наличие товаров и сравнивает цены с текущим каталогом. Если один товар подорожал с момента, когда пользователь открыл корзину, domain возвращает конкретную ошибку. Если товара не хватает, он возвращает другую. Эти названия важны, потому что handler может аккуратно сопоставить их с ответом.

Storage-пакет сохраняет успешный результат в одной транзакции. Он вставляет строку заказа, записывает зарезервированные товары и подтверждает транзакцию только если все записи успешны. Domain не важно, происходит ли это в PostgreSQL или где-то ещё. Он лишь просит repository сохранить принятый заказ.

Практичное сопоставление выглядит так:

  • Битый JSON или отсутствующие поля: 400
  • Customer не может сделать заказ прямо сейчас: 403 или 409, в зависимости от правила
  • Товара нет в наличии: 409
  • Цена изменилась до оформления: 409
  • Неожиданная ошибка storage: 500

Тесты остаются сфокусированными, когда правило живёт в domain. Вы можете вызвать order-service с fake customer store и fake inventory store, а затем проверить, что заблокированный customer получает нужную ошибку или что нехватка товара останавливает заказ. Без HTTP-сервера. Без реальной базы данных. Это делает тесты быстрыми и позволяет легко менять бизнес-правило позже.

Где здесь место для тестов

Сделайте разработку удобнее для AI
Более понятная кодовая база помогает AI-ревью, тестированию и автоматизации работать лучше.

Структура пакетов Go-сервиса поддерживается легче, когда тесты следуют тем же границам. Если каждый тест поднимает всё приложение, suite становится медленным, шумным и перестаёт вызывать доверие.

Handler-тесты должны оставаться на transport-границе. Используйте httptest, чтобы отправлять запросы через handler, а затем проверяйте то, что увидел бы клиент: плохой input должен падать аккуратно, валидный input должен возвращать правильный status code, а тело ответа должно сохранять ожидаемую JSON-форму.

Делайте эти тесты узкими. Handler-тесту не нужно доказывать, что правила ценообразования работают, или что запрос к базе написан верно. Ему нужно лишь проверить, что handler парсит input, корректно вызывает следующий слой и пишет ожидаемый response.

Domain-тесты должны быть самыми дешёвыми в репозитории. Лучше всего они работают с обычными структурами и прямыми вызовами функций, без HTTP, без базы и без настройки framework'а. Если ваш order-service отклоняет quantity, равное нулю, или ограничивает скидку, проверяйте это правило именно здесь и больше нигде.

Storage-тесты требуют больше реализма. SQL-моки могут помочь в крайнем случае, но они часто пропускают сломанные запросы. Для кода, который читает и пишет реальные данные, лучше использовать настоящую базу данных, обычно в контейнере, и проверять запросы, scans и mapping-логику на окружении, близком к production.

Такие тесты стоят дороже, поэтому держите их сфокусированными. Один тест на вставку заказа, один на чтение обратно, один на типичную ошибку обычно достаточно.

End-to-end тесты тоже важны, но их должно быть немного. Используйте несколько штук, чтобы проверить связку от router до domain и storage. Один happy path и один очевидный путь с ошибкой часто находят пропавшую конфигурацию, неправильную настройку зависимостей и сломанный поток запроса, не заставляя вас повторять каждую ветку на верхнем уровне.

Когда появляется баг, пишите тест на самом низком слое, который может его воспроизвести. Это сохраняет suite быстрым и сразу показывает, где именно проблема.

Ошибки, которые добавляют лишний церемониал

Репозиторий с шестью пакетами и тремя бизнес-правилами читать сложнее, а не проще. Хорошие границы пакетов убирают путаницу. Плохие заставляют открывать пять файлов, чтобы проследить один запрос.

Самая частая ошибка — добавлять interfaces везде. Если у одной структуры одна реализация и её никто не подменяет ни в тестах, ни во время работы, лучше оставить конкретный тип. Interface должен решать реальную задачу, а не украшать код. Store interface, который существует только потому, что так велела «clean architecture», обычно становится балластом.

Небольшие функции тоже часто тонут в слишком большом числе папок. Handler, service, mapper, repository, model, dto и validator-пакет для одного маленького endpoint'а — это уже слишком много церемоний для простого потока. Если у функции один transport-input, одно domain-правило и один запрос, держите эти части ближе друг к другу. Углублять дерево можно тогда, когда код действительно вырастет.

Избегайте ящика с полезными мелочами

Пакет utils — это место, где понятный код исчезает. Сначала случайные helpers выглядят безобидно, потом их начинают импортировать все пакеты, и вот уже ни у чего нет понятного дома. Кладите код туда, где он имеет смысл для бизнеса или для границы. Если функция помогает только storage-коду, держите её рядом со storage. Если она только формирует HTTP-response, держите её рядом с transport.

Копирование одного и того же типа через все пакеты создаёт такой же лишний вес. Если CreateOrderRequest, CreateOrderInput и CreateOrderCommand содержат одни и те же четыре поля, вы не моделируете систему лучше — вы просто больше печатаете. Преобразовывайте типы только на реальных границах. HTTP-payload часто нуждается в собственной форме. Database-row часто тоже. А domain-тип посередине должен существовать потому, что он означает что-то другое, а не потому, что каждому слою нужен свой ярлык.

Несколько типичных запахов повторяются снова и снова:

  • один interface на одну структуру, даже когда абстракцию никто не использует
  • глубокие деревья папок для крошечных функций
  • общий utils-контейнер для несвязанного кода
  • дублирующиеся типы, которые отличаются только именем пакета

Последняя ошибка — считать одну структуру правилом для всех сервисов. Небольшой внутренний worker не обязан выглядеть так же, как публичный API с несколькими transport-слоями и активным storage-уровнем. Структура пакетов Go-сервиса должна следовать давлению в кодовой базе. Начинайте с малого, разделяйте только там, где швы уже очевидны, и позволяйте репозиторию заслужить дополнительные пакеты.

Быстрые проверки перед добавлением ещё одного пакета

Спланируйте безопасный refactor
Двигайтесь по одной функции за раз и продолжайте выпускать изменения, пока репозиторий становится проще.

Большинство новых пакетов появляются слишком рано. В небольшом Go-сервисе новая папка должна убирать путаницу прямо сейчас, а не обещать когда-нибудь более красивое будущее, которое может никогда и не понадобиться.

Начните с задачи. Если пакет делает одну вещь и вы можете описать её одним простым предложением, у него, скорее всего, есть причина существовать. «Этот пакет хранит заказы в Postgres» — понятно. «Этот пакет содержит общие helpers для заказов, валидации, mapping и retries» — уже тревожный сигнал.

Названия важны не меньше. Другой разработчик должен находить код без догадок. Если парсинг запросов находится в одном месте, бизнес-правила — в другом, а database-код — в третьем, путь к ним должен быть очевиден уже по названиям папок. Когда люди постоянно спрашивают «куда это положить?», структура всё ещё мутная.

Тесты дают честный ответ. Разделение полезно тогда, когда тесты становятся меньше, а подготовка — легче. Если перенос кода в новый пакет означает больше mocks, больше interfaces и больше test fixtures, вы, скорее всего, добавили церемониал вместо ясности.

Imports — ещё один быстрый сигнал. Здоровое разделение сокращает линии зависимостей. В структуре пакетов Go-сервиса domain-код не должен тянуть за собой HTTP-детали, а storage-код не должен знать о JSON-форме запроса. Если новый пакет добавляет больше cross-package imports, чем убирает, лучше оставить код вместе ещё немного.

Перед каждым переносом помогает короткая проверка:

  • Дайте пакету описание в одну строку.
  • Спросите, где новый коллега будет искать его в первую очередь.
  • Сравните тест до и после разделения.
  • Посмотрите, стало ли меньше imports или они разошлись в стороны.

Если вы колеблетесь по двум или трём пунктам, подождите. Хорошие границы пакетов ощущаются скучно. Люди читают дерево, открывают ожидаемый файл и идут дальше.

Что делать дальше, если репозиторий продолжает расти

Когда в репозитории уже три-четыре функции, переносить файлы по наитию обычно только ухудшает ситуацию. Начинайте на бумаге. Нарисуйте дерево папок, которое вам нужно, до того как что-либо перемещать, даже если оно пока грубое. Такой шаг сразу показывает дублирующиеся пакеты, расплывчатые названия и папки, которые существуют только потому, что когда-нибудь могут пригодиться.

Затем выберите один путь через сервис и реорганизуйте сначала только его. Возьмите запрос, который проходит через transport, выполняет domain-логику и заканчивается в storage. Обычно достаточно потока создания или обновления. Если этот путь стало легче читать и менять, вы двигаетесь в правильную сторону.

Практичная структура пакетов Go-сервиса должна упрощать следующий change, а не делать красивее диаграмму. Маленьким репозиториям не нужна идеальная чистота слоёв. Им нужен код, который уставший коллега сможет понять во вторник вечером, не открывая двенадцать файлов.

Хорошая точка остановки выглядит так:

  • handlers знают HTTP и парсинг запросов
  • domain-код знает бизнес-правила
  • storage-код знает SQL и детали хранения
  • поток данных между ними легко прослеживается

Останавливайтесь, когда код становится понятнее. Не продолжайте делить пакеты только потому, что форма кажется более «правильной». Дополнительные швы имеют цену. Больше пакетов — значит больше названий, больше imports и больше мест, где могут спрятаться мелкие типы.

Если команда снова и снова возвращается к одному и тому же спору, позовите свежий взгляд со стороны. Oleg Sotnikov помогает растущим Go-сервисам как Fractional CTO и помогает командам выбрать структуру, которая подходит уже существующему коду, а не навязывает шаблон для гораздо более большой системы.

Обычно именно такой темп и нужен для подобного refactor'а: набросать дерево, перенести один полный путь, сохранить то, что сделало сервис понятнее, и пока оставить остальное как есть.