09 янв. 2025 г.·7 мин чтения

DDD-lite на Go без фреймворка: структура пакетов

DDD-lite на Go без фреймворка помогает правильно организовать пакеты, размещать интерфейсы и управлять транзакциями, не борясь с простым Go-кодом.

DDD-lite на Go без фреймворка: структура пакетов

Почему простые Go-проекты становятся запутанными

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

Такой стиль папок скрывает то, чем реально занимается программа. Пользователь создаёт заказ, продлевает подписку или приглашает коллегу — это реальные действия. Но код группируется по техническим ролям, и бизнес-история исчезает.

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

И даже небольшое изменение расползается повсюду. Допустим, вы добавили правило в checkout: блокировать заказы, если товар зарезервирован, но не подтверждён. Звучит локально. В грязном коде это может затронуть хендлер, сервис, два метода репозитория, общий модельный пакет и три набора тестов. Правило простое. Структура делает его дорогим.

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

Результат знаком:

  • чтение одного use case занимает слишком много времени
  • простые изменения затрагивают слишком много файлов
  • тесты становятся медленными и хрупкими
  • выбор базы данных просачивается в бизнес-код

Именно поэтому более лёгкий стиль DDD привлекает многие Go-команды. Суть не в добавлении новых паттернов. Суть — держать код, относящийся к одной задаче, рядом, чтобы поток был очевиден, а правила — легко тестируемы.

Что значит DDD-lite в простом Go

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

DDD-lite оставляет главное: код должен соответствовать бизнесу. Если приложение обрабатывает заказы, биллинг или правила доступа, эти концепции должны быть явно видны в коде. Не нужно везде ставить агрегаты, фабрики и репозитории только потому, что какая-то книга их использовала.

Простой тест помогает: можно ли открыть один пакет и понять, за какое правило он отвечает? Если checkout решает, можно ли оплатить заказ, то это правило должно жить рядом с кодом checkout, а не в пакете базы данных или разбросано по хендлерам.

Небольшие пакеты обычно работают лучше, чем один большой доменный слой. Один пакет может владеть checkout заказов. Другой — выставлением счетов. Каждый пакет может содержать несколько простых типов, один-два сервиса и функции, которые реализуют правила. Часто этого достаточно.

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

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

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

Выбирайте границы пакетов по работе

Этот подход начинается с одного действия пользователя. Выберите что-то конкретное, например «создать заказ», и формируйте пакеты вокруг этой работы. Если вы начинаете с слоёв типа models, services и utils, код обычно превращается в гору общих частей без очевидного владельца.

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

Для потока заказа небольшая структура вроде этой работает:

internal/
  order/
    create.go
    cancel.go
    order.go
    repository.go
  http/
    order_handler.go
  postgres/
    order_repo.go
  queue/
    order_events.go

Пакет order владеет бизнес-работой. HTTP-хендлеры превращают запросы в вызовы. Postgres-код сохраняет данные. Код очередей публикует события. Каждый внешний пакет делает одну работу и уходит в сторону.

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

Большие общие пакеты обычно — тревожный сигнал. Пакет с именем common, shared или helpers начинается маленьким, затем все фичи импортируют его. После этого менять что-то становится рискованно, потому что цепочка зависимостей тянет везде.

Хорошая эвристика проста. Если новый разработчик спрашивает: «Где живёт создание заказа?», вы должны указать один пакет, а не пять. Когда границы пакетов соответствуют реальной работе, Go-код остаётся простым — его легче читать, легче менять и сложнее случайно сломать.

Размещайте интерфейсы там, где их используют

Когда один пакет нуждается в помощи другого, пакет с потребностью должен определять интерфейс. Этот пакет владеет контрактом. Пакет хранения или API лишь реализует его.

Это сохраняет направление зависимостей. Бизнес-код говорит: «Мне нужны эти два или три метода», вместо того, чтобы импортировать большой тип репозитория и подгонять логику под него.

Пакет checkout — хороший пример. Он может нуждаться в загрузке клиента, сохранении заказа и резервировании запаса. Это небольшой, ясный контракт. Если вы используете общий интерфейс репозитория с десятью методами, пакет начинает зависеть от деталей, которые он не использует.

Малые интерфейсы также делают тесты менее утомительными. Можно написать маленький фейк с только теми методами, которые использует use case. Это занимает несколько минут. Мокать большой интерфейс превращается в рутинную работу.

Возвращайте доменные значения из методов, а не строки таблиц или ORM-модели. Если checkout работает с Customer, Order и StockReservation, возвращайте эти типы. Не протекайте CustomerRow или OrderRecord в прикладной код. Когда схема меняется, пакет хранения должен поглотить изменение, а не каждый вызывающий.

Этот стиль работает лучше, когда интерфейсы находятся близко к use case. Пакет, который координирует workflow, знает, что ему нужно, лучше, чем общая папка interfaces.

Та верхняя папка выглядит аккуратно сначала, но затем наполняется неясными именами вроде Repository, Store и Service. Скоро несвязанные пакеты зависят от одинаковых контрактов, и простые изменения становятся сложнее из-за слишком широких абстракций.

Держите правило простым:

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

Код легче читать, когда каждый пакет просит ровно то, что ему нужно, и ничего лишнего.

Держите транзакции в приложенческом слое

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

Транзакция должна покрывать один use case. Не больше и не меньше. Если «checkout заказа» затрагивает запас, состояние платежа и запись заказа, application-сервис должен открыть одну транзакцию и контролировать её от начала до конца.

Методы репозитория не должны самостоятельно начинать и фиксировать транзакции за спиной. Это удобно сначала, но ломается, как только один use case требует нескольких записей, которые должны выполниться вместе. В итоге вы получаете наполовину записанные данные и неудобный код для исправления.

На практике поток короткий: начать транзакцию, создать репозитории, привязанные к ней, выполнить проверки и записи для use case, затем зафиксировать только если каждый шаг завершился успешно. Если что-то упало — откатить сразу.

Держите sql.Tx вне домена. Ваши Order, Invoice или политики не должны знать о дескрипторах базы. Доменные объекты должны содержать бизнес-правила, а не детали хранения. Как только sql.Tx просачивается в этот слой, тесты становятся шумными, а границы пакетов — размытыми.

Более чистая форма — позволить приложенческому слою собирать репозитории, работающие на транзакции, из инфраструктурного слоя. Application-сервис уже координирует работу, так что он может создать orders, inventory и payments репозитории, которые все используют ту же транзакцию. Домен при этом видит обычные методы вроде Save, Reserve или MarkPaid.

Небольшой пример

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

Этот стиль упрощает рассуждения о сбоях. Вы знаете, где транзакция начинается, где она заканчивается и кто её владеет. Такая простая ясность — обычно лучший выбор.

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

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

Чистый поток использует один application-сервис для координации шагов. Репозитории занимаются работой с базой. Клиент платежа и mailer остаются вне слоя базы.

Хороший checkout-поток выглядит так:

  • Открыть одну транзакцию базы.
  • Зарезервировать запас, создать заказ и вставить запись платежа со статусом вроде pending.
  • Зафиксировать эту транзакцию.
  • Вызвать платежного провайдера после фиксации.

Этот порядок важен. Вы резервируете товар до списания денег, чтобы не брать оплату за то, что нельзя отправить. Также вы записываете заказ и запись платежа вместе, чтобы база никогда не осталась с только одним из них.

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

После возврата платежного вызова приложение открывает вторую короткую транзакцию. Если списание успешно — помечает платёж как оплаченный и подтверждает заказ. Если списание не удалось — помечает платёж как неудачный и освобождает запас.

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

Чтобы поток оставался читабельным, держите его в одном use case с маленькими хелперами. Границы транзакций должны быть очевидны при беглом просмотре кода. В чистом Go это обычно выигрывает у сокрытия процесса за хитрыми абстракциями.

Стройте шаг за шагом

Настроить лучшие интерфейсы
Держите контракты маленькими и размещайте их там, где нужен use case.

Начинайте с работы, а не с дерева папок. Самая чистая структура пакетов обычно появляется после того, как вы выпишете реальные use case'ы, которые нужно поддерживать. Перечислите их как действия: создать счёт, отменить подписку, отправить напоминание, синхронизировать склад.

Этот список даёт вам реальные границы. Пакет без use case'а за ним обычно превращается в ящик для хлама.

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

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

Простой порядок работы:

  1. Напишите метод use case в application-слое.
  2. Определите только те интерфейсы, которые вызывает метод.
  3. Держите эти интерфейсы рядом с методом, который их использует.
  4. Поместите код базы в отдельный адаптер-пакет.
  5. Добавляйте транзакцию только если несколько записей должны выполниться вместе.

Этот порядок держит код честным. Вы не угадываете абстракции заранее. Use case формирует их по мере надобности.

Маленькие интерфейсы помогают больше, чем широкие. Если CreateInvoice нуждается только в FindCustomerByID и SaveInvoice, определите именно их. Не добавляйте delete, list, update или batch методы только потому, что репозиторий может понадобиться позже.

Код хранения принадлежит внешней стороне. Ваш пакет Postgres или MySQL может реализовать эти интерфейсы, не выталкивая SQL-детали в application-слой. Это облегчает тестирование и сохраняет архитектуру простой.

Осторожно используйте транзакции. Если поток пишет счёт и аудиторскую запись, которые должны быть синхронизированы, откройте транзакцию вокруг всего use case. Если метод лишь читает данные или два записи могут происходить независимо, пропустите транзакцию. Слишком много транзакций усложняет чтение и изменение кода.

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

Ошибки, которые усложняют изменения

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

Одна распространённая ошибка — создавать репозиторий на каждую таблицу. Это выглядит организованно, пока один use case не потребует orders, payments, stock и discounts в одном потоке. Тогда application-слой должен координировать таблицо-уровневые куски вместо того, чтобы просить одну бизнес-операцию. Если checkout должен резервировать склад и создавать заказ, код должен отражать эту работу, а не схему.

Общий пакет models даёт другой вид грязи. Сначала он экономит время, но вскоре хендлеры, репозитории и бизнес-логика зависят от одних и тех же структур. Тогда изменение хранения просачивается в API, или поле для HTTP пролезает в доменные правила. Держите типы близко к пакету, который владеет поведением.

Открытие транзакций в HTTP-хендлерах — ещё плохая практика. Хендлер должен декодировать ввод, вызвать application-сервис и вернуть ответ. Если хендлер начинает транзакцию, правила транзакций расползаются по транспортному коду. Тот же поток копируется в другой endpoint с чуть другим обработкой ошибок.

Позволять домену импортировать пакеты базы создаёт ещё больше трения. Как только домен знает о SQL-ошибках, query builder'ах или типах драйвера, он перестаёт быть чистым бизнес-кодом. Тесты становятся тяжёлыми, и простые правила зависят от инфраструктуры.

Последняя ловушка — добавление абстракций до их необходимости. Не нужно базовый репозиторий, пять интерфейсов и unit-of-work package в первый день. Начните с прямого кода. Добавляйте интерфейс, когда пакет действительно нуждается в шве. Добавляйте транзакционные хелперы, когда более чем один репозиторий должен выполниться вместе.

Go-проекты обычно портятся одним небольшим решением за раз. Поэтому скучная структура — хороший признак. Если пакет легко объяснить, его обычно и проще менять.

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

Проверка структуры Go
Получите второе мнение о границах пакетов, интерфейсах и владении транзакциями.

Большинство Go-проектов не терпят не из-за нехватки пакетов. Они становятся запутанными, потому что каждая новая идея сразу становится пакетом до того, как код покажет необходимость. Пакет должен заработать своё место.

Новый пакет имеет смысл, когда вы можете назвать его задачу в одном коротком предложении. Если нужно полстраницы объяснений, граница ещё неясна. «Обрабатывает checkout заказа» — ясно. «Содержит общие бизнес-хелперы для checkout, pricing и payment stuff» — не ясно.

Пакет также должен владеть частью работы. Если он тянет логику из нескольких соседних пакетов, чтобы завершить один use case, он, вероятно, пересекает архитектуру не в том месте. В чистой структуре импорты должны в основном смотреть внутрь на нижележащие детали, а не боком в сеть соседних пакетов.

Прежде чем создать папку, спросите себя:

  • Могу ли я описать задачу этого пакета в одном простом предложении?
  • Владеет ли он одним кусочком потока, а не битами отовсюду?
  • Могу ли я протестировать его правила с маленьким фейком или стабом?
  • Избегают ли его импорты тянуться через соседние пакеты?
  • Могут ли вызывающие использовать его без знания SQL-таблиц, join'ов или строк запросов?

Последняя проверка экономит много боли. Если приложение вынуждено передавать сырые SQL, имена таблиц или сканить строки, граница пакета неверна. Вызывающие должны говорить на бизнес-языке вроде FindOrder, SaveInvoice или ReserveStock.

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

Простой пример: не делайте discounts пакет только потому, что две хендлера используют одинаковую математику. Сделайте пакет только если он владеет правилами скидок. Иначе держите код близко к checkout и подождите. Небольшая задержка чаще приводит к лучшим границам пакетов, чем ранние догадки.

Что делать дальше

Начните с одного потока, который уже немного болит. Выберите что-то маленькое, но реальное: создать заказ, отправить счёт или одобрить возврат. Перенесите сначала только этот поток. Вы получите больше пользы от одного аккуратного рефактора, чем от переименования половины репозитория за выходные.

Держите объём узким. Назначьте ясный application-сервис для потока, вынесите доступ к данным за интерфейсы там, где код их использует, и сделайте одно место ответственным за транзакцию. Если изменение кажется скучным — это обычно хороший знак. Простой Go-код должен оставаться простым.

Короткий чеклист:

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

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

После каждой новой фичи снова посмотрите на границы транзакций. Команды часто стартуют чисто, затем скатываются к передаче объектов транзакции через три слоя, потому что так быстрее. Эта хитрость становится дорогой. Если одна фича действительно нуждается в широкой транзакции, сделайте это осознанно и держите контроль в application-слое.

Если кодовая база уже большая, не ждите плана полного переписывания. Исправьте один путь, повторите паттерн, и старые части подтянутся при следующем касании. Маленькие шаги приживаются лучше.

Если нужна внешняя проверка, Oleg Sotnikov на oleg.is работает со стартапами и малыми командами по архитектуре Go, границам пакетов, инфраструктуре и практическим AI-процессам разработки. Короткая консультация может заметить неясную структуру до того, как она разойдётся по кодовой базе.

Часто задаваемые вопросы

Что значит DDD-lite в терминах простого Go?

Это значит, что код формируется вокруг реальных действий вроде CreateOrder или Checkout, а не вокруг папок models и services. Держите правила, типы и поток работы рядом, а HTTP, SQL и очереди — на краю.

Нужны ли агрегаты, фабрики и полный доменный слой?

Нет. Начните с простых структур, методов, функций и небольших интерфейсов. Добавляйте паттерн только тогда, когда появляется реальная потребность — лишние обёртки часто скрывают поток вместо того, чтобы прояснять его.

Как правильно разделить пакеты в Go-приложении такого рода?

Группируйте код по фичам или use case'ам. Пакет order может владеть checkout, cancel, типами и контрактами, а http — обрабатывать запросы, postgres — хранить данные. Если кто-то спросит, где живёт создание заказа, вы должны указать на один пакет.

Где должны жить интерфейсы?

Размещайте интерфейсы в пакете, который их использует. Если checkout нуждается в методах LoadCustomer и SaveOrder, определите этот малый контракт в checkout, а код хранения реализует его. Это сужает зависимости и упрощает тесты.

Что должны возвращать методы репозитория?

Возвращайте бизнес-значения, такие как Order или Customer, а не OrderRow или ORM-модели. Когда детали схемы остаются в адаптере хранения, изменения затрагивают меньше файлов, и код use case'а читается проще.

Кто должен открывать и закрывать транзакции базы данных?

Пусть application-сервис владеет транзакцией для одного use case'а. Он может начать, создать репозитории, привязанные к транзакции, и выполнить commit или rollback. Хендлеры и доменные типы не должны управлять sql.Tx.

Можно ли вызывать платёжные или почтовые API внутри транзакции?

Обычно нет. Записывайте локальные изменения в короткой транзакции, фиксируйте их, а затем вызывайте внешний сервис. Долгие сетевые вызовы удерживают блокировки и усложняют обработку ошибок. Если результат API меняет состояние заказа, откройте вторую короткую транзакцию.

Как понять, стоит ли создавать новый пакет?

Создавайте пакет только если его задачу можно описать в одном простом предложении и если его правила можно протестировать маленькими фейками. Если пакет собирает куски по всему коду или выставляет SQL-детали наружу — подождите и держите логику ближе к фиче.

Можно ли тестировать большинство правил без реальной БД?

Да. Перенесите решения о скидках, правах и правилах checkout в простой Go-код, а затем замокайте небольшие интерфейсы вокруг хранения. Реальная база данных нужна только для тестов адаптеров, а не для каждой бизнес-правилы.

Нужно ли переписывать весь код, чтобы внедрить это?

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