Архитектурная доктрина для NestJS-проектов: разбор типовых сценариев деградации кодовой базы и структурные ограничения, обеспечивающие её отсутствие при росте функционала. Навигация по серии:

  1. Часть 1. Эволюция NestJS-приложения в неподдерживаемое состояние

  2. Часть 2. Декомпозиция на сервисы: анализ ограниченности подхода

  3. Часть 3. Архитектурный риск циклов в NestJS: ROI решений на горизонте пяти лет

  4. Часть 4. FBCA: формализация границ ответственности в NestJS-модуле

  5. Часть 5. Масштабирование FBCA и теоретико-графовый анализ зависимостей

В частях 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 });
  }
}

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

  1. Зачем мы вообще внедряли feature-based-clean, если бизнес-логика идентична FB-варианту?

  2. Кода стало больше, не меньше. Где обещанная читабельность?

  3. Что я как разработчик выигрываю прямо сейчас, если итог — те же самые шаги, только с большим количеством папок и абстракций?

Эти вопросы выглядят как опровержение всей идеи. На самом деле они подсвечивают то, что мы уже зафиксировали в финале части 4: FBCA — это не про сегодняшний код, а про код через спринт-два-три. Если сравнивать только текущий снимок, FB и FBCA выглядят примерно одинаково — разница в том, что у одного есть точки, на которых можно остановиться, а у другого нет. Эта разница не видна, пока не появилась архитектурная нагрузка. И именно её мы сейчас увидим в действии.

Давайте посмотрим как с течением времени развивался модуль Users по правилам feature-based-clean. Пока handler сценария регистрации обрастал внешними сервисами, сам модуль Users тоже развивался — фронт просил профиль, маркетинг — статистику, аналитика — счётчики. К текущему моменту у Users уже не тот маленький модуль с двумя use-case’ами, который мы проектировали в части 4. Теперь это полноценный домен:

  • получение профиля пользователя

  • обновление профиля (bio, avatar, username)

  • обновление настроек аккаунта

  • приватность аккаунта (public / private)

  • получение базовой статистики (подписчики, подписки)

  • управление пользовательскими настройками (язык, тема и т. д.)

  • получение текущего пользователя (me endpoint)

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.

Граф зависимостей FBCA: модули Auth и Users
Граф зависимостей FBCA: модули Auth и Users

Теперь посмоторим на граф FB до того, как там начинаются циклы.

Граф зависимостей FBCA: модули Auth и Users
Граф зависимостей FBCA: модули Auth и Users

Разумеется, 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. На графе это выглядит вот так.

Граф зависимостей FBCA: модули Auth и Users
Граф зависимостей FBCA: модули Auth и Users

Можно ли это доказать математически?

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

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 она остаётся константой.

При каких условиях это работает

Все три утверждения обусловлены тремя инвариантами:

  1. Функция L монотонна — никаких рёбер «вверх»: handler не зовёт presentation, repository не зовёт use-case.

  2. Каждый модуль публикует только external/-порт. Внутренние handler’ы и репозитории нигде наружу не экспортированы.

  3. Use-case не зовёт соседний use-case. Общая логика опускается вниз — в репозиторий или в домен, — а не размазывается вбок.

Без выполнения этих условий доказательства не работают. То есть строго утверждать можно только следующее:

При соблюдении инвариантов слоёв и external-портов граф зависимостей является DAG’ом, размер API ограничен размером external-поверхности, и стоимость добавления фичи константна.

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

Давайте теперь визуализируем процесс масштабирования проекта и моудля Users, сначала упростим визуализацию графов и сделаем снимок текущего состояния

Граф зависимостей FBCA: модули Auth и Users
Граф зависимостей FBCA: модули Auth и 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 после расширения админки.

И что важно: в каждом из этих случаев новая ручка — это новая папка рядом, без правок старых. Граф растёт вертикально, а не вширь.

Граф зависимостей FBCA: модули Auth и Users
Граф зависимостей FBCA: модули Auth и Users

Граф вырос в три раза, а форма у него та же. Те же пять коробок, те же стрелки сверху вниз. Если завтра придёт ещё три релиза и use-case’ов станет шестьдесят — картинка структурно не изменится: те же пять коробок, просто внутри HTML-меток станет тесновато. Никаких новых рёбер, никаких новых сущностей графа.

Это и есть то, что называется «архитектура осталась в форме». Не потому что мы её не трогали — мы добавили десятки новых файлов. А потому что мы её не деформировали: не пришлось вводить новые типы связей, переписывать существующие use-case’ы под новые требования или разруливать накопившиеся компромиссы. Каждое требование легло как новая папка рядом, и старые папки этим не заинтересовались.