12 февр. 2025 г.·6 мин чтения

DDD-lite архитектура с чёткими границами домена

DDD-lite держит модули маленькими, имена простыми, а application services — тонкими. Научитесь разделять код по доменам без громоздких правил.

DDD-lite архитектура с чёткими границами домена

Почему границы становятся размытыми

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

Так изменение в биллинге в конце концов начинает читать таблицы пользователей напрямую, потому что данные «уже там». Экран поддержки начинает вызывать классы оплаты, потому что никто не хочет дублировать одно правило. Сегодня это экономит время, а в следующем месяце обходится гораздо дороже.

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

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

Потом язык начинает скользить. Термин вроде «customer» сначала имеет одно значение, затем тихо приобретает другое. В одной части приложения это компания, которая платит. В другой — конечный пользователь с логином. Оба значения кажутся близкими, поэтому никто не разделяет их. Позже люди видят одно и то же слово и представляют разные вещи.

Эта путаница проникает в код. Один метод обновляет запись «customer» и ломает отчёт. Другая проверка блокирует возврат, потому что читает неправильное поле статуса. Небольшие правки начинают ломать части приложения, к которым вы не прикасались. Обычно это первый явный признак слабых границ.

Здесь помогает DDD-lite. Он не требует тяжёлого процесса или набора паттернов. Он просит более простую привычку: держать бизнес-правила рядом с тем бизнес-концептом, к которому они относятся, и не позволять каждой фиче заимствовать отовсюду.

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

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

Начните с небольшого числа модулей, которые соответствуют тому, как бизнес уже говорит. Если команда ежедневно употребляет «billing», «accounts» и «subscriptions», эти имена должны быть и в коде. Вам не нужны десять слоёв или полный набор DDD-паттернов, чтобы получить выгоду.

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

Для многих продуктов это означает модули вроде accounts, billing, subscriptions и notifications. Точное число важнее не само по себе, а причина разделения. Каждый модуль должен соответствовать реальной бизнес-области, а не технической корзине вроде «services» или «helpers».

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

Тонкие application services очень помогают. Они должны координировать работу, а не принимать бизнес-решения. Тонкий сервис может загрузить аккаунт, вызвать доменный метод вроде pause(), сохранить результат и опубликовать событие при необходимости. Как только application services начинают решать скидки, льготные периоды или лимиты возврата — границы быстро стираются.

Пропустите уровни, которые только переименовывают одно и то же действие три раза. Если контроллер вызывает use case, который вызывает manager, который вызывает processor, а в каждом файле только одна строка делает реальную работу — такая структура создаёт шум. Оставьте части, которые проясняют ответственность. Уберите те, что лишь удлиняют путь.

Простой язык важнее, чем кажется. Если поддержка говорит «trial ended», используйте этот термин в модели. Если финансы говорят «credit note», не прячьте это за именем вроде AdjustmentEntity. Чёткие имена уменьшают ошибки, ускоряют ввод в курс дела и делают обзоры кода намного менее утомительными.

Выбирайте модули по языку бизнеса

Хорошие имена модулей обычно приходят из слов, которыми люди уже пользуются в тикетах, звонках продаж и чатах поддержки. Если клиенты говорят «refund», «invoice», «seat», «trial» и «cancellation», эти слова важнее папок вроде controllers, services и utils. Такой подход упрощает, когда код звучит как бизнес.

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

Слова вроде account, subscription, invoice, refund и seat часто указывают на границы модулей. Так же и действия: renew, cancel, charge, refund, upgrade. Billing может владеть charge, invoice и refund. Orders — правила оформления и покупки. Support — рабочие процессы тикетов и заметки о клиенте. Обычно это чище, чем деление на api, db, hooks и helpers, потому что папки фреймворка смешивают несвязанные бизнес-правила.

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

На примере небольшого SaaS это легко увидеть. Если клиент апгрейднулся, был двойной платёж и он просит возврат, этот поток не должен прыгать между случайными service-файлами. Billing должен обрабатывать charge и refund-правила. Support фиксирует запрос и статус. Orders может вообще не участвовать. Такое разделение даёт более чёткие границы и упрощает поиск багов.

Будьте осторожны с общим кодом. Команды часто переносят код в common или shared слишком рано, и тогда каждый модуль начинает зависеть от одной корзины. Сначала оставьте некоторое дублирование. Переносите код только когда повторение реально тратит время, вызывает баги или заставляет менять одно и то же правило в нескольких местах.

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

Как держать application services тонкими

Application service должен читаться как короткий сценарий. Он получает запрос, загружает нужные доменные объекты, просит доменную модель сделать работу, сохраняет изменения и возвращает результат, понятный вызывающему.

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

Тонкий сервис обычно делает четыре вещи: принимает команду (например, «approve invoice» или «cancel subscription»), загружает нужные объекты из хранилища, вызывает доменные методы, которые содержат бизнес-правило, и сохраняет обновлённое состояние. Потом он возвращает понятный результат: «approved», «rejected» или «already canceled».

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

Эта логика должна быть ближе к модели или в небольшом policy-объекте, который модель использует. Сервис может передать дату, роль пользователя или факты оплаты, но не должен превращаться в свалку if-ов.

Хороший тест прост: можете ли вы прочитать сервис сверху вниз за несколько секунд? Если да — он, вероятно, достаточно тонкий. Если нужно пролистывать ветки, циклы и вспомогательные вызовы, чтобы понять одно бизнес-действие, сервис делает слишком много.

Например, сервис отмены подписки может загрузить подписку и billing policy, вызвать subscription.cancel(today, policy), сохранить подписку и вернуть результат. Правило о частичном возврате денег должно жить внутри доменного поведения, а не в методе сервиса.

Этот стиль делает изменения безопаснее. Когда правило по возвратам меняется, вы правите доменный код в одном месте. Application service остаётся тем же: принять, загрузить, вызвать, сохранить, вернуть.

Чёткие результаты тоже помогают. Если сервис возвращает «past refund window» вместо ERR_42, другим разработчикам сразу понятно, что произошло, без рытья в коде.

Называйте модель простым языком

Prepare For AI Workflows
Clean boundaries first so automation and AI tools fit your codebase.

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

Refund понятнее, чем RefundProcessorFactory. Первое имя указывает на то, что понимают в поддержке, финансах и продукте. Второе описывает сантехнику. У сантехники есть место, но оно на краю кода, а не в центре.

То же относится к поведению. Если заказ можно отменить, Order.cancel() говорит это прямо. Имя вроде doOrderCancellationFlow() звучит как сценарий, а не как бизнес-действие. Оно также намекает, что реальное правило живёт где-то ещё.

Держите одно значение для каждого термина. Если «order» иногда означает черновой корзину, иногда оплаченный заказ, а иногда запрос на доставку — код быстро расплывётся. Выберите одно значение и используйте его в классах, методах, событиях и полях. Если в бизнесе на самом деле два понятия — дайте им два имени.

Обобщённые хелперы создают худшую путаницу. Имена вроде process(), handle(), manager или utils скрывают причину существования кода. Переименовав их в соответствии с реальным назначением, вы легче заметите слабые границы.

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

  • processRefund() становится refund.approve() или refund.reject()
  • OrderManager становится OrderCancellationPolicy, если он проверяет, можно ли отменить
  • DataHelper становится RefundReasonParser, если он парсит ввод клиента
  • execute() становится sendRefundReceipt(), если это реальная задача

Это не только стиль. Чёткие имена снижают стоимость изменений. Новый разработчик может быстрее прочитать модель и понять бизнес. Основатель или менеджер продукта может пробежаться по именам методов и заметить несоответствия до того, как они распространятся. Когда язык прост, тонкие application services остаются тонкими, потому что модель сама несёт смысл.

Простой пример: возвраты в небольшом SaaS

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

В структуре DDD-lite Support только открывает запрос. Она фиксирует, кто запросил, почему и какие есть детали. Support не решает политику. Агент поддержки может сказать «Клиент запросил возврат после одного экспорта», но не должен решать, укладывается ли запрос в правила.

Orders владеет фактами покупки. Он знает, что купил клиент, когда начался доступ, активен ли план и что клиент уже использовал. Billing владеет правилами оплаты. Он знает, открыт ли ещё окно возврата для этого чека и можно ли ещё вернуть платёж.

Application service координирует поток. Он запрашивает у Orders сводку по покупке. Затем отправляет платёжные данные и факты заказа в Billing для решения. Если Billing одобряет возврат, application service просит Billing его выполнить, просит Orders пометить заказ как возвращённый и возвращает результат в Support.

Результат остаётся простым: approved, rejected because the refund window closed, rejected because it was already refunded или needs review.

Такое разделение держит модули честными. Orders отвечает за факты покупки. Billing отвечает за политику денег. Support ведёт коммуникацию с клиентом. Application service переводит запрос от шага к шагу, но не прячет бизнес-правила внутри оркестрации.

Это также облегчает изменение. Если компания увеличивает окно возврата с 14 до 30 дней — меняется Billing. Если Support добавляет новое поле в форму — меняется Support. Если команда начинает учитывать новый вид использования продукта — меняется Orders. Один запрос на возврат остаётся одним ясным потоком вместо стопки смешанных обязанностей.

Ошибки, которые снова размывают границы

Audit Your Architecture Drift
Spot shared helpers, hidden dependencies, and ownership drift before they spread.

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

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

Другая проблема — когда сервисы лезут в приватные запросы друг друга. Billing просит Accounts «сырое» ответное представление из базы. Потом код поддержки начинает так же обращаться к внутренностям billing. После этого каждый модуль знает слишком много о таблицах, флагах и пограничных случаях других модулей. Граница есть в дереве папок, но её нет на практике.

Контроллеры тоже могут сильно навредить, когда код запроса собирает бизнес-правила. Контроллер проверяет права, окна возврата, лимиты купонов, правила плана и статус аккаунта до вызова application service. Сначала это кажется быстрым. Потом второй endpoint нуждается в той же логике, и люди копируют код с мелкими отличиями. Баги появляются, потому что правило теперь живёт в веб-слое, а не в домене.

Более тонкая ошибка — дрейф владения. Один модуль хранит данные, но другой модуль объясняет, что эти данные означают. Например, Billing владеет инвойсами, но Support содержит реальную логику допустимости возврата, потому что support понадобилось это первым. Billing превращается в хранилище, а Support становится местом, где живёт бизнес-смысл. Такое разделение всех запутывает.

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

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

Fix One Messy Flow
Bring one refund, billing, or subscription flow and map clear ownership.

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

Достойная DDD-lite настройка проходит несколько скучных тестов. Скучно — хорошо. Если ответы расплывчаты, дополнительная структура обычно усложнит ситуацию.

Новый разработчик должен найти нужный модуль меньше чем за минуту. Если ему приходится гадать между core, shared, common и utils — имена слишком общие. Billing, Refunds или Subscriptions легче читать и сложнее неправильно использовать.

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

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

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

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

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

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

Следующие шаги к чище кодовой базе

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

Это небольшое упражнение делает две вещи быстро. Оно показывает, где код смешивает бизнес-решения с технической работой, и даёт команде общее представление о том, что куда относится. В DDD-lite это представление важнее дополнительных паттернов или диаграмм.

Практическое правило на эту неделю: вынесите одно бизнес-правило из сервиса в доменный объект. Держите это маленьким. Если возврат возможен только в течение 14 дней, или триал может начаться только один раз, правило должно жить рядом с тем, что оно описывает. Тонкие application services передают данные, вызывают нужные объекты и на этом останавливаются.

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

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

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

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