01 дек. 2024 г.·6 мин чтения

DDD-lite с gRPC между внутренними модулями на практике

DDD-lite с gRPC сохраняет правила транспорта на границах модулей, в то время как доменная модель остаётся простой, легче тестируется и меньше связана с кодом фреймворка.

DDD-lite с gRPC между внутренними модулями на практике

Проблема, которую это решает

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

Сначала это кажется быстрым. Потом небольшое изменение начинает ломать код в местах, которые не должны быть с ним связаны.

Команда биллинга добавляет поле только для транспорта в gRPC-вызове. Вскоре домен заказов несёт метаданные запроса, обёртки статусов или значения enum, которые имеют смысл только «на проводе». После этого детали фреймворка распространяются внутрь. Простое бизнес-правило вроде «можно ли списать с этого заказа?» перестаёт быть чистой функцией и начинает зависеть от сгенерированных типов, правил protobuf или обработки ошибок gRPC.

Код при этом продолжает работать. Просто читать его становится сложнее, потому что бизнес-логика смешалась с транспортной логикой.

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

Эту проблему обычно можно заметить рано:

  • изменение protobuf ломает код, который и не делает сетевых вызовов
  • модули импортируют структуры друг друга лишь чтобы избежать маппинга
  • доменные тесты нуждаются в объектах gRPC-запросов, чтобы проверить простые правила
  • внутренние API начинают формировать бизнес-модель вместо обратного

Лёгкий подход DDD решает это, не превращая кодовую базу в теоретическую конструкцию. Цель практична: держать доменную модель модуля простой, доверить gRPC контрактам транспорт, и переводить данные на границах.

Это даёт чистый шов между модулями. Биллинг может выставить gRPC-контракт, не заставляя заказы думать в терминах protobuf-сообщений весь день. Домен остаётся компактным, тестируемым и, в хорошем смысле, скучным. При этом сохраняются строгие границы между модулями, но без тяжёлых слоёв и архитектурных упражнений ради самой архитектуры.

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

Что означает DDD-lite на практике

DDD-lite — это небольшая, практичная версия domain-driven design. Не нужно применять все паттерны из книг. Оставьте те части, которые помогают команде выпускать код с меньшей путаницей: понятные границы модулей, общий словарь и доменный код, который читается как набор правил.

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

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

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

Язык важнее, чем многие думают. Если один модуль называет сущность «customer», другой — «account», а третий — «user», ошибки появляются быстро. Не нужна идеальная теория. Нужны согласованые термины в коде, контрактах, логах и разговоре команды.

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

Держите доменную модель простой

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

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

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

Например, есть правило, которое блокирует возврат денег после 30 дней. Доменный метод не должен принимать объект gRPC-запроса, server context или сериализатор. Он должен принимать дату покупки, текущую дату и состояние заказа, а затем возвращать ясное решение.

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

Здесь граница окупается. Контракты остаются на границе модуля, где им и место, а домен свободен меняться вместе с бизнесом. Если позже вы переименуете поле protobuf или смените транспорт, само правило останется нетронутым.

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

Используйте контракты только на границах

Внутри одной кодовой базы protobuf должен оставаться в слое транспорта. Рассматривайте .proto файлы как конверты. Они определяют то, что пересекает границу модуля, но не то, как работают бизнес-правила. Логика ценообразования, правила заказа и изменения состояния должны жить в простых структурах или классах, которые ничего не знают о gRPC.

Чистая граница обычно предполагает один небольшой адаптер с каждой стороны. Адаптер получает protobuf-запрос, решает транспортные вопросы и маппит поля в доменный ввод. Затем доменный код работает со своими типами, возвращает доменный результат, а адаптер преобразует его обратно в protobuf-ответ.

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

Сгенерированные типы сообщений создают проблемы, когда они просачиваются внутрь. Они притаскивают детали транспорта в тесты, привязывают код к сериализатору и замедляют рефакторинг. Хендлер вроде CreateInvoice(ctx, *billingpb.CreateInvoiceRequest) (*billingpb.InvoiceResponse, error) нормален на границе. Код под ним должен выглядеть больше как CreateInvoice(input CreateInvoiceInput) (Invoice, error).

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

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

Это также делает изменения дешёвыми. Вы сможете позже заменить gRPC или вызывать тот же модуль в процессе без изменений в домене. Так внутренние границы переживают первую волну быстрой доставки.

Разделяйте модули по одному

Simplify Your Technical Stack
Обрежьте лишнюю сложность в сервисах, CI и инфраструктуре, прежде чем она разрастётся.

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

Начните с доменных типов. Опишите простые структуры или классы, которые объясняют бизнес-идею, а не формат передачи. Идентификатор заказа, сумма денег или запрос на оплату должны иметь смысл в тестах без gRPC, базы данных или сгенерированного кода вокруг них.

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

Далее создайте protobuf-сообщения для транспорта, а не для домена. Эти сообщения должны соответствовать тому, что вызывающие стороны отправляют и получают через границу. Им не нужны все внутренние поля, и они не должны раскрывать приватное состояние только потому, что его легко сериализовать.

После этого маппьте входящий запрос в доменный ввод в gRPC-хендлере. Хендлер должен превратить строки, числа и enum в простые доменные значения, а затем вызвать сервис. Держите этот слой скучным. Скучный код проще доверять.

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

Простой пример делает это наглядным. Допустим, модуль заказов просит биллинг зарезервировать $49. gRPC-запрос может нести order_id, сумму и валюту. Хендлер конвертирует это в простой доменный ввод, биллинг решает, разрешена ли резервация, и ответ присылает идентификатор резервации плюс статус.

Это разделение даёт два чистых тестовых пути. Доменные тесты вызывают billing-сервис напрямую. Тесты транспорта проверяют валидацию и маппинг. Если оба остаются короткими — граница, вероятно, на своём месте.

Простой пример с заказами и биллингом

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

Клиент оформляет заказ на три места с ежемесячным планом. Модуль заказов сначала проверяет свои правила: существует ли клиент, активен ли план и можно ли перевести заказ из черновика в подтверждённый. Эта работа остаётся внутри модуля заказов как простой код и простые объекты. В этой логике не должно быть типов gRPC.

Модуль заказов может собрать доменный объект типа Order, вычислить свой статус и сохранить его. После этого он отправляет небольшой запрос в биллинг. Этот запрос — только транспорт. Это не модель заказа.

Запрос на создание счета обычно требует лишь нескольких полей: order_id, customer_id, currency, позиции с количеством и ценой за единицу и due_date. Этого достаточно, чтобы биллинг сделал свою работу. Биллинг не должен получать весь агрегат заказа, кучу внутренних флагов или историю статусов только потому, что gRPC позволяет сериализовать больше данных.

Биллинг затем применяет свои правила. Он может сгруппировать позиции, добавить налог, округлить суммы или отклонить запрос, если валюта не поддерживается. Эти правила принадлежат биллингу, а не заказам.

Та же разделённость работает и в обратную сторону. Заказы владеют изменениями статуса заказа. Биллинг может сообщать факты вроде invoice_created или payment_failed, но он не должен напрямую делать order.status = paid. Заказы должны переводить внешние факты в собственные изменения состояния по своим правилам.

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

Ошибки, которые создают тесную связанность

Review Your Service Boundaries
Получите практическую проверку границ модулей, контрактов и настройки тестирования.

Связанность обычно начинается с удобного сокращения. Команда повторно использует protobuf-сообщение как бизнес-тип, пропускает маппер и экономит час. Через пару месяцев поле, добавленное для транспорта, ломает доменный код в трёх модулях.

Это случается, потому что protobuf-сообщения — контракты, а не бизнес-типы. Они несут транспортные заботы: опциональные поля, форматы на проводе, обратную совместимость, правила именования и enum, созданные для стабильности API. Ваша доменная модель должна оставаться простой. Она должна читаться как бизнес-код, а не как сгенерированная схема.

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

Команды также создают один большой контракт, который пытается покрыть все случаи использования. Результат — раздутый proto-файл с полями, которые нужны лишь одному вызову. Скоро все запросы и ответы выглядят похоже, но каждый означает что-то своё. Такой контракт стареет плохо: никто не хочет ничего удалять.

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

Общие helper-пакеты могут скрыть проблему на время. Часто появляются папки вроде common/mappers или proto_utils. Никто ими не владеет. Все правят их. Там накапливаются баги маппинга, потому что ни один модуль не считает этот код своей ответственностью.

Пару ранних признаков можно заметить сразу:

  • доменные тесты требуют сгенерированных protobuf-типов
  • один модуль читает таблицы другого ради «одного запроса»
  • proto-файлы растут быстрее доменного кода
  • helper-пакеты знают слишком много о нескольких модулях

Oleg Sotnikov часто говорит об этом просто: архитектура портится, когда размывается владение. Если одна команда не может поменять контракт, таблицу или маппер без сюрприза для другой команды, модули уже связаны. Решение обычно простое и того стоит: держите доменные объекты внутри, мапьте явно на границе и дайте каждому контракту явного владельца.

Проверки перед релизом

Clean Up Test Friction
Сделайте модульные тесты быстрыми снова, оставив сгенерированные типы за пределами домена.

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

Запустите тесты бизнес-правил без gRPC-сервера, БД или сети. Если вы можете создать заказ, применить скидку или отклонить плохой ввод в памяти — доменный код остаётся простым.

Замените реальный gRPC-клиент на крошечный фейк в юнит-тестах. Если тесту нужно лишь пару возвращаемых полей, модуль зависит от контракта, а не от настройки транспорта.

Прочитайте файл контракта отдельно. Он должен описывать данные запроса и ответа для транспорта, а не весь ваш внутренний бизнес‑стейт, приватные правила или форму внутренних объектов.

Проверьте владение данными. Заказы должны владеть данными заказа и правилами работы с ними. Биллинг — данными счетов и правилами оплат. Один модуль не должен лазить в таблицы другого или в его внутренние структуры.

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

Небольшой пример помогает. Допустим, биллингу нужна итоговая сумма по заказу. Модуль заказов может вернуть order_id, валюту, суммы по позициям и статус через gRPC. Биллинг использует эти данные, чтобы решить, создавать счёт или нет. Биллинг не должен импортировать доменные типы заказов только потому, что они уже есть.

Здесь команды часто ошибаются. Они вкладывают бизнес‑смысл в контракт, копируют этот контракт глубоко в доменный код, и тесты начинают требовать серверов и сгенерированных клиентов. Код продолжает работать, но каждое изменение замедляется.

Одно правило стоит держать в уме: если юнит‑тест для бизнес‑правила требует сетевой порт, граница уже размыта. Исправьте это до релиза. Это мелкая уборка сейчас и болезненный перепис позже.

Следующие шаги для растущей кодовой базы

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

Не делайте полный перепис. Выберите один шов, который уже болит: модуль с частыми изменениями, медленными тестами или неясной ответственностью. Зафиксируйте текущий контракт, затем проследите все места, где сгенерированные protobuf или gRPC-типы появляются за пределами хендлеров и клиентов. Это даст реальную карту связности вместо догадок.

Большинство команд получают один и тот же первый набор работ. Переместите сгенерированные типы из доменных сервисов и сущностей. Добавьте мапперы рядом с хендлерами и gRPC-клиентами. Оставьте use case'ы и доменный код на простых структурах или объектах. Перепишите тесты так, чтобы они вызывали доменную модель без настройки транспорта.

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

После первого очищения сделайте регрессии дороже. Держите сгенерированный код в отдельном пакете и воспринимайте импорт его в доменные пакеты как маркер для ревью. Даже простое правило в code review помогает: доменные пакеты не знают, как выглядит gRPC.

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

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