Архитектурная доктрина для NestJS-проектов: разбор типовых сценариев деградации кодовой базы и структурные ограничения, обеспечивающие её отсутствие при росте функционала.
В частях 1–2 мы наблюдали, как простой signUp под напором требований превращался в god-сервис на сотни строк, который знает про десяток модулей сразу. В части 3 разобрали, как из этого естественно рождаются forwardRef и циклические зависимости — тот самый клубок, который уже не распутать. В части 4 спроектировали FBCA-архитектуру с нуля — domain / use-case / infrastructure / presentation со слоями, external-портами и Result-обвязкой ошибок — и показали, как тот же signUp выглядит в ней.
Часть 5 — параллельная вселенная того же сюжета. Те же бизнес-требования, что прилетели в feature-based в частях 1–2, теперь прилетают в FBCA-кодовую базу из части 4: handler регистрации обрастает теми же внешними сервисами (анти-фрод, рефералки, партнёры, аналитика), модуль Users — теми же фичами (профиль, настройки, приватность, статистика). Смотрим, что меняется по форме, что — по содержанию, и почему граф зависимостей остаётся ацикличным даже после многократного роста. В конце — формальное обоснование на языке теории графов: почему DAG-инвариант, граница связности и константная стоимость инкремента — это не свойство архитектуры в смысле «удобно», а её математическое содержание.
Помните набор требований из части 2 — тот, под весом которого AuthService.signUp превратился в god-сервис на восемьсот строк, обросший шестью соседними сервисами и одним обратным forwardRef’ом? Тот же самый список сейчас прилетает в FBCA-кодовую базу:
-
партнёрская программа с блогерами и стримерами
-
разные модели монетизации (revenue share, бонусы, уровни)
-
более сложный анти-фрод (несколько сценариев и скоринг)
-
расширенная аналитика (маркетинг, продукт, финансы)
-
дополнительные проверки и ограничения для рефералок
Бизнес-логика обязана остаться той же — иначе сравнение нечестное. А вот форма кода и графа зависимостей — посмотрим, как они изменятся. Оговорка про транзакции из части 1 продолжает действовать здесь и далее — мы рассматриваем декомпозицию, а не транзакционную целостность.
export type SignUpInput = { email: string; password: string; referralCode?: string; adSourceCode?: string; ip?: string; deviceId?: string;};@Injectable()export class SignUpHandler { constructor( private readonly bonusExternalService: BonusExternalService, private readonly usersExternalService: UsersExternalService, private readonly partnerExternalService: PartnerExternalService, private readonly adSourceExternalService: AdSourceExternalService, private readonly referralExternalService: ReferralExternalService, private readonly analyticsExternalService: AnalyticsExternalService, private readonly antiFraudExternalService: AntiFraudExternalService, ) {} async run( input: SignUpInput, ): Promise<Result<SignUpResult, SignUpErrorCode>> { const { email, password, referralCode, adSourceCode, ip, deviceId } = input; // анти-фрод const checkAntiFraudResult = await this.antiFraudExternalService.checkSignUp({ ip, deviceId }); if (checkAntiFraudResult.isErr()) { return err("SIGN_UP_ANTI_FRAUD_FAILED"); } if (!checkAntiFraudResult.value.allowed) { return err("SIGN_UP_ANTI_FRAUD_REJECTED"); } // проверяем, что юзер ещё не зарегистрирован — до любых side-effect'ов const findUserResult = await this.usersExternalService.getUserByEmail(email); if (findUserResult.isErr()) { return err("SIGN_UP_GET_USER_FAILED"); } if (findUserResult.value) { return err("SIGN_UP_USER_ALREADY_EXISTS"); } // источник трафика / A/B const applyAdSourceResult = await this.adSourceExternalService.applyAdSource(adSourceCode); if (applyAdSourceResult.isErr()) { return err("SIGN_UP_AD_SOURCE_FAILED"); } // реферал / партнёр (модуль сам решает, что это за код) const resolveReferralResult = await this.referralExternalService.resolve( referralCode, email, ); if (resolveReferralResult.isErr()) { return err("SIGN_UP_REFERRAL_RESOLVE_FAILED"); } const createUserResult = await this.usersExternalService.createUser({ email, password, adSource: applyAdSourceResult.value, ip, deviceId, }); if (createUserResult.isErr()) { return err("SIGN_UP_CREATE_USER_FAILED"); } const referral = resolveReferralResult.value; const user = createUserResult.value; // бонусы if (referral?.kind === "user") { const giveReferralBonusResult = await this.bonusExternalService.giveReferralBonus(referral.ownerId); if (giveReferralBonusResult.isErr()) { return err("SIGN_UP_BONUS_FAILED"); } const createReferralResult = await this.referralExternalService.createReferral( referral.ownerId, user.id, ); if (createReferralResult.isErr()) { return err("SIGN_UP_REFERRAL_CREATE_FAILED"); } } if (referral?.kind === "partner") { const processPartnerResult = await this.partnerExternalService.processPartner(referral); if (processPartnerResult.isErr()) { return err("SIGN_UP_PARTNER_FAILED"); } const givePartnerRewardResult = await this.bonusExternalService.givePartnerReward( processPartnerResult.value, ); if (givePartnerRewardResult.isErr()) { return err("SIGN_UP_PARTNER_REWARD_FAILED"); } const trackPartnerRewardResult = await this.analyticsExternalService.trackPartnerReward( processPartnerResult.value, ); if (trackPartnerRewardResult.isErr()) { return err("SIGN_UP_ANALYTICS_FAILED"); } } // финальная аналитика const trackRegistrationResult = await this.analyticsExternalService.trackRegistration({ userId: user.id, source: applyAdSourceResult.value?.code, ip, }); if (trackRegistrationResult.isErr()) { return err("SIGN_UP_ANALYTICS_FAILED"); } return ok({ id: user.id, email: user.email }); }}
Как вы можете наблюдать, логика, запросы и алгоритмы остались точно такими же. Тогда у читателя возникают логичные вопросы:
-
Зачем мы вообще внедряли
feature-based-clean, если бизнес-логика идентична FB-варианту? -
Кода стало больше, не меньше. Где обещанная читабельность?
-
Что я как разработчик выигрываю прямо сейчас, если итог — те же самые шаги, только с большим количеством папок и абстракций?
Эти вопросы выглядят как опровержение всей идеи. На самом деле они подсвечивают то, что мы уже зафиксировали в финале части 4: FBCA — это не про сегодняшний код, а про код через спринт-два-три. Если сравнивать только текущий снимок, FB и FBCA выглядят примерно одинаково — разница в том, что у одного есть точки, на которых можно остановиться, а у другого нет. Эта разница не видна, пока не появилась архитектурная нагрузка. И именно её мы сейчас увидим в действии.
Давайте посмотрим как с течением времени развивался модуль Users по правилам feature-based-clean. Пока handler сценария регистрации обрастал внешними сервисами, сам модуль Users тоже развивался — фронт просил профиль, маркетинг — статистику, аналитика — счётчики. К текущему моменту у Users уже не тот маленький модуль с двумя use-case’ами, который мы проектировали в части 4. Теперь это полноценный домен:
-
получение профиля пользователя
-
обновление профиля (bio, avatar, username)
-
обновление настроек аккаунта
-
приватность аккаунта (public / private)
-
получение базовой статистики (подписчики, подписки)
-
управление пользовательскими настройками (язык, тема и т. д.)
-
получение текущего пользователя (
meendpoint)
src/modules/users/├── domain/ # User, UserProfile, UserSettings, UserPrivacy, ...│├── use-case/│ ├── presentation/ # Сценарии для UsersController│ │ ├── get-profile/│ │ ├── update-profile/│ │ ├── update-account-settings/│ │ ├── update-privacy/│ │ ├── update-preferences/│ │ ├── get-user-stats/│ │ └── get-current-user/│ ││ └── external/ # Сценарии для других модулей (через external-порт)│ ├── create-user/ # ← Auth│ ├── get-user-by-email/ # ← Auth│ ├── check-user-exists/ # ← Likes, Comments, Follows│ ├── get-following-ids/ # ← Feed│ ├── can-view-content/ # ← Feed│ ├── can-receive-notification/ # ← Notifications│ ├── get-public-user-info/ # ← Comments, Likes│ ├── is-searchable/ # ← Search│ ├── is-user-blocked/ # ← Moderation, AntiFraud│ └── get-user-status/ # ← разные модули│├── infrastructure/│ └── repositories/│ ├── user/│ ├── user-profile/│ ├── user-settings/│ ├── user-privacy/│ ├── user-preferences/│ ├── user-stats/│ └── user-session/│├── external/ # Порт для других модулей (Auth, Feed, Comments, Search, ...)│└── presentation/ # UsersController + DTO
Как мы видим, необходимость в большом рефакторинге «разделение по ответственности» отпала. В feature-based, когда UsersService достигает критической массы — скажем, восемьсот строк и пятнадцать методов про разные подсистемы пользователя, — наступает время для большой переделки. Раздробить на UserProfileService, UserSettingsService, UserPrivacyService. Это серьёзная работа: переписать вызовы, разрулить циклы, обновить тесты, пересобрать модули. Месяц-полтора как минимум — и всё это время система должна продолжать работать на проде.
В FBCA этой точки просто нет. Когда продакт приходит с «нужны настройки приватности» — это use-case/presentation/update-privacy/ рядом с уже существующими update-profile/ и update-account-settings/. Когда DBA говорит «хорошо бы вынести user_sessions в отдельную таблицу» — это infrastructure/repositories/user-session/ рядом с user/ и user-profile/. Каждый новый артефакт ложится рядом со старыми, ничего не сдвигая. Папок становится больше, но это не разрастание — это горизонтальный рост, который читается так же, как пять файлов читались.
А когда use-case’ов становится двадцать-тридцать и в одной директории глаза начинают разбегаться, разделение «по ответственности» сводится к одной механической операции: разложить готовые кусочки лего по тематическим папкам. update-profile/, update-bio/, update-avatar/ уезжают в use-case/presentation/profile/. update-privacy/, update-account-settings/ — в use-case/presentation/security/. Никаких зависимостей разруливать не надо, никаких циклов, никакой Nest DI-перенастройки. PR может оказаться жирным — но только из-за того, что меняются пути импортов; ни одной строчки логики при этом вы не правите.
Давайте теперь посмотрим на граф зависимостей, который образовался в результате FBCA.
Теперь посмоторим на граф FB до того, как там начинаются циклы.
Разумеется, FB здесь проигрывает: множество соседних сервисов начали тянуть код из UsersService, и кажется, что если убрать эту зависимость, всё будет хорошо.
Это самообман. Стрелки — не ленивая разработка, а отражение бизнеса: реферальная программа правда должна знать, кто кого пригласил; анти-фрод — видеть историю аккаунта; аналитика — обогащать события профилем. Убрать зависимости нельзя — модули перестанут работать.
Те же потребности есть и в FBCA — выше столько же стрелок, но они ведут в UsersExternalService. Разница в том, что каждая такая стрелка — это не «универсальный ключ ко всему Users», а конкретное полномочие: одному соседу разрешено звать только getUserByEmail, другому — только isUserBlocked. В FB любой, у кого есть UsersService в DI-графе, имеет доступ к полному API хаба, и завтра может позвать оттуда метод, о котором никто не договаривался. В FBCA это очевидное нарушение контракта: чего нет в external/, того для соседа не существует.
Даже если вы уберёте зависимость внутренних сервисов от UsersService, заведёте отдельные репозитории под каждую сущность и наладите дисциплину «сервис не зовёт сервис» — это не спасёт надолго.
Дело в том, что сервис как единица организации кода устроен так, что не может не разрастаться. Один класс — много методов. Сегодня UserProfileService.updateBio() и updateAvatar(). Завтра ещё getProfileMetadata(), recalculateCompletenessScore(), markAsViewed(). Через спринт в нём двадцать публичных методов, и любой импортёр получает доступ ко всем сразу. Проблема не в том, что Profile зависит от Users — проблема в том, что зависимость на класс автоматически даёт доступ ко всему классу.
Вот тут и появляется use-case в FBCA-смысле — и это не «бизнесовый сценарий» в привычном смысле слова. Use-case здесь — структурная единица: одна папка, один handler, одна операция. update-profile/ — это не «вся работа с профилем», это конкретно «обновить bio/avatar/username». Чтобы добавить «получить метаданные профиля», нужна новая папка get-profile-metadata/ рядом, а не новый метод в существующем handler’е. Сама форма не позволяет операциям накапливаться в одну точку.
Это и снимает проблему. Когда импортёру нужна одна операция, он импортирует ровно её — а не весь сервис. Когда появляется новая операция, она лежит рядом, не сдвигая старую. Граф зависимостей перестаёт стягиваться к хабам, потому что хабам некуда стягиваться: каждый узел — атомарная единица.
В части 3 мы уже разбирали, как из одного обратного импорта рождается forwardRef. На графе это выглядит вот так.
Можно ли это доказать математически?
Если коротко — само по себе утверждение «архитектура не деградирует» нематематическое. Деградация определяется людьми, и любую структуру можно сломать сознательным игнором. Но структурные свойства, при выполнении которых деградация затруднена определённым образом, доказать формально можно. Разберём три таких свойства.
1. DAG-инвариант
Граф зависимостей в системе разбивается на два уровня — внутри одного модуля и между модулями. Докажем, что оба уровня — DAG.
Внутримодульный граф. Введём:
-
V_M— множество классов модуляM(handler’ы, репозитории, доменные типы, презентация, external-порт) -
E_M ⊆ V_M × V_M— внутримодульные рёбра «зависит от» -
L: V_M → ℕ— функция слоя в рамках одного модуля:domain = 0,infrastructure = 1,use-case = 2,external = 3,presentation = 4
Утверждение. Если для любого ребра (x, y) ∈ E_M выполнено L(x) > L(y), то внутримодульный граф (V_M, E_M) не содержит ни одного цикла. Технически такой граф называется DAG’ом, Directed Acyclic Graph.
Доказательство. Допустим, в графе есть цикл x₁ → x₂ → ... → xₙ → x₁. Тогда из условия:
L(x₁) > L(x₂) > ... > L(xₙ) > L(x₁)
что даёт L(x₁) > L(x₁) — противоречие. Цикла быть не может. Что значит этот переход. Цикл — это путь, который возвращает нас в исходную точку. Но каждое ребро в этом пути строго опускает значение L: с первого класса на второй (L(x₁) > L(x₂)), со второго на третий, и так далее. К концу пути мы обязаны оказаться строго ниже стартового значения — и при этом одновременно в исходной точке, где L снова равно стартовому. «Строго ниже» и «то же самое» одновременно — невозможно. Это и есть противоречие.
В FBCA эта функция существует по построению: каждый файл живёт в одном из слоёв, и направление импортов задано конвенцией. External выступает фасадом над use-case’ами своего модуля — он импортирует handler’ы (L(external) = 3 > L(use-case) = 2), а не наоборот.
Межмодульный граф. Внутримодульная L-функция ничего не говорит про связи между модулями — для них работает отдельное правило.
-
V_inter— множество модулей системы -
Рёбра — «модуль X импортирует код модуля Y»
Правило. Любой импорт из модуля X в модуль Y разрешён только через external/-порт модуля Y. Сам external других модулей не импортирует — это его конструктивное свойство: он декларирует поверхность своего модуля и делегирует во внутренние handler’ы того же модуля, и больше ничего.
Следствие. Цикл M₁ → M₂ → ... → Mₙ → M₁ потребовал бы, чтобы каждый модуль в цепочке импортировал external следующего. Но если M₁.external ничего извне модуля не импортирует, ребро Mₙ → M₁ идти попросту неоткуда — это нарушение самого правила. Цикл между модулями топологически невозможен.
Итог: граф системы — DAG как внутри каждого модуля (через функцию слоя), так и между модулями (через external-only-правило).
Это формализация фразы «зависимости текут только в одну сторону». В feature-based ни L, ни external-правила нет: сервисы могут импортировать друг друга в любом направлении, поэтому циклы топологически возможны — что и реализуется на практике (тот самый случай из части 3).
Что это значит на практике (и при чём тут forwardRef)
Самый понятный способ объяснить, что такое DAG, — представить, что вы идёте по графу пешком, по стрелкам.
В FBCA каждый шаг строго опускает вас по слоям: с presentation к use-case, с use-case к infrastructure, с infrastructure к domain. Дошли до domain — оттуда стрелок дальше нет. Прогулка завершилась естественно.
В графе с циклом такого «конца» не существует. Вы пошли по стрелке, потом по второй, потом по третьей — и обнаружили, что вернулись туда, откуда стартовали. И пойдёте по тому же кругу снова, и снова, и так до бесконечности. Единственный способ остановиться — отдельно запоминать «здесь я уже был».
Это ровно то, что NestJS делает через forwardRef. Когда DI-контейнер встречает циклическую зависимость, он не может разрешить её «честно» — пришлось бы создать A, чтобы передать его в B, но B нужен для создания A. forwardRef говорит контейнеру: «погоди инстанцировать сейчас, я вернусь к этой связи позже». То есть фреймворк сам делает себе отметку «уже видели», чтобы не уйти в зацикливание.
В FBCA-структуре такой костыль не нужен в принципе. Графу некуда зацикливаться — у каждой прогулки по стрелкам есть естественный конец на доменном слое. NestJS резолвит зависимости в один проход, без отметок и без forwardRef.
2. Граница связности (coupling bound)
Связность модуля = размер его публичного API.
В feature-based:
API(M) = объединение всех публичных методов всех сервисов, экспортированных из M
Этот размер растёт автоматически с каждым новым методом любого сервиса.
В FBCA:
API(M) = публичные методы M.external
Размер растёт только при явном добавлении операции в external/-порт.
Следствие. При равном функционале |API_FBCA(M)| ≤ |API_FB(M)|, потому что в FB любой публичный метод сервиса автоматически становится частью API модуля, а в FBCA — только то, что явно выложено в external/.
В терминах теории информации: связность как взаимная информация между потребителем и модулем ограничена размером API. FBCA-структура жёстко лимитирует поверхность, через которую возможно случайное связывание.
3. Стоимость изменения (cost of change)
Пусть F — добавляемая фича.
В FB при добавлении метода в god-сервис S:
-
Запись новой логики:
O(1)строк -
Скрытое расширение поверхности:
O(|consumers(S)|)потребителей теперь имеют доступ к новой операции -
Возможные регрессии: пропорциональны числу потребителей
В FBCA при добавлении новой операции:
-
Создание новой папки
<verb-noun>/с handler’ом и модулем:O(1)строк -
Скрытое расширение поверхности:
O(0)— ни один существующий handler не меняется, ни один external-сервис не расширяется
Качественно:
IncrementalCost_FB(F) ∈ O(|consumers|)IncrementalCost_FBCA(F) ∈ O(1)
Это и есть та «плоскость стоимости», о которой шла речь раньше: в FB цена каждой следующей фичи растёт с числом существующих потребителей; в FBCA она остаётся константой.
При каких условиях это работает
Все три утверждения обусловлены тремя инвариантами:
-
Функция
Lмонотонна — никаких рёбер «вверх»: handler не зовёт presentation, repository не зовёт use-case. -
Каждый модуль публикует только
external/-порт. Внутренние handler’ы и репозитории нигде наружу не экспортированы. -
Use-case не зовёт соседний use-case. Общая логика опускается вниз — в репозиторий или в домен, — а не размазывается вбок.
Без выполнения этих условий доказательства не работают. То есть строго утверждать можно только следующее:
При соблюдении инвариантов слоёв и external-портов граф зависимостей является DAG’ом, размер API ограничен размером external-поверхности, и стоимость добавления фичи константна.
То, что доказывается — это конкретные структурные свойства при выполнении конкретных правил. Именно эти свойства (отсутствие циклов, ограниченная связность, константная стоимость инкремента) и составляют реальное содержание термина «не деградирует».
Давайте теперь визуализируем процесс масштабирования проекта и моудля Users, сначала упростим визуализацию графов и сделаем снимок текущего состояния
Давайте представим, что прошёл год эксплуатации в продакшене. Состав use-case’ов на графе ниже — это не превентивная декомпозиция, а накопленный ответ на конкретные требования и инциденты: каждая группа возникла как ответ на конкретный запрос продакта, инцидент или регуляторное требование. Кратко по группам с указанием источника каждой.
Presentation (что юзер делает со своим аккаунтом):
-
Профиль. Базовая страница «у каждого юзера есть профиль» обросла отдельными ручками после A/B-тестов на inline-редактирование (bio, avatar) и инцидентов со сменой
usernameи подделкойdisplayName. -
Настройки. Поддержка устала от тикетов «не могу отписаться от писем» — выделили email-prefs в отдельный flow. Privacy и blocking-rules — под GDPR и расширенные блокировки. Language и theme разнесены потому, что у каждой свой сторонний поток (i18n-кэш, sync темы между устройствами).
-
Я и мои данные.
me-endpoint и stats — базовая поверхность для фронта. Engagement-stats добавилась после релиза creator-tools. -
GDPR / Compliance. Удаление с 14-дневным окном, восстановление и экспорт всех данных в JSON — обязательная троица по GDPR/CCPA.
External (что нужно соседним модулям):
-
Identity. Базовые операции «создать / найти / проверить юзера». Часть введена с первого дня (для Auth), часть — после инцидентов:
bulk-get-usersпосле первой медленной ленты с N+1,check-user-existsпосле анализа DB-нагрузки («зачем тянуть объект, если нужен boolean»),display-info— облегчённый профиль для рендера в комментариях. -
Graph. Социальный граф: подписки, видимость, оповещения.
can-view-contentпоявился после релиза приватных аккаунтов,check-blocked-by— после инцидента, когда Feed выдавал контент юзера, заблокировавшего смотрящего. -
Access. Полномочия и статусы: блокировки, верификация, тиры (после монетизации),
get-user-sinceдля анти-фрода (новый аккаунт = меньше доверия),permissionsпосле расширения админки.
И что важно: в каждом из этих случаев новая ручка — это новая папка рядом, без правок старых. Граф растёт вертикально, а не вширь.
Граф вырос в три раза, а форма у него та же. Те же пять коробок, те же стрелки сверху вниз. Если завтра придёт ещё три релиза и use-case’ов станет шестьдесят — картинка структурно не изменится: те же пять коробок, просто внутри HTML-меток станет тесновато. Никаких новых рёбер, никаких новых сущностей графа.
Это и есть то, что называется «архитектура осталась в форме». Не потому что мы её не трогали — мы добавили десятки новых файлов. А потому что мы её не деформировали: не пришлось вводить новые типы связей, переписывать существующие use-case’ы под новые требования или разруливать накопившиеся компромиссы. Каждое требование легло как новая папка рядом, и старые папки этим не заинтересовались.
ссылка на оригинал статьи https://habr.com/ru/articles/1038450/