javascript

Feature Based Clean Architecture. Часть 3: Архитектурный риск циклов в NestJS: ROI решений на гориз…

  • воскресенье, 24 мая 2026 г. в 00:00:23
https://habr.com/ru/articles/1038426/

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

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

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

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

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

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

Теперь посмотрим, чего эта «декомпозиция на сервисы» стоит на горизонте чуть длиннее одного спринта. У нас в продукте уже есть FollowsService — аккуратно живёт в своём модуле, складывает связи «followerId → targetUserId» в свою таблицу, ничего лишнего не знает. Параллельно — UsersModule с разнесёнными на отдельные сервисы кусочками: UserPrivacyService и UserSettingsService. Каждый отвечает за свою грань пользователя. Single responsibility, имплементация чистая, ревью — праздник.

Дальше через саппорт начинают идти жалобы: незнакомцы пишут в личку всякую гадость, юзеры хотят прятать свои профили от случайных глаз. Запрос понятный, человеческий — продакт его подхватывает: делаем приватные аккаунты. На приватный профиль нельзя подписаться напрямую — отправляешь follow request, владелец одобряет или нет. Логика на полстраницы в Notion. Идея тривиальная, день работы, ну, два.

Кейс: приватные аккаунты и подписки

Тикет приземляется в бэкенд. Что нужно поменять? Перед тем как FollowsService создаст запись в follows, надо сходить и узнать, приватный ли аккаунт у того, на кого подписываются. Если да — кладём заявку в follow_requests и ждём одобрения от владельца. Если нет — продолжаем как обычно. Знание о приватности живёт в UsersModule (где ему ещё жить?), значит FollowsService начинает зависеть от UserPrivacyService. На ревью такая зависимость пройдёт за тридцать секунд.

@Injectable()
export class FollowsService {
  constructor(private readonly userPrivacyService: UserPrivacyService) {}

  async follow(
    followerId: string,
    targetUserId: string,
  ): Promise<FollowResponse> {
    const isPrivateResult =
      await this.userPrivacyService.isPrivate(targetUserId);

    if (isPrivateResult.isErr()) {
      throw new InternalServerErrorException(isPrivateResult.error);
    }

    if (isPrivateResult.value) {
      return this.createFollowRequest(followerId, targetUserId);
    }

    return this.createFollow(followerId, targetUserId);
  }

  private async createFollowRequest(
    followerId: string,
    targetUserId: string,
  ): Promise<FollowResponse> {
    // создать заявку на подписку
  }

  private async createFollow(
    followerId: string,
    targetUserId: string,
  ): Promise<FollowResponse> {
    // создать подписку
  }
}

На этом этапе всё действительно в порядке. FollowsService спросил у UsersModule: «приватный или нет?» — и пошёл дальше. Стрелочка зависимости одна, идёт в одну сторону, на схеме архитектора рисуется без петель. Если бы фичи на этом и кончились — у нас бы не было ни статьи, ни проблемы. Но фичи, как правило, не кончаются.

Через пару спринтов в продукте появляется второй сценарий: viewer заходит на чужой профиль. И тут вылезает целый пакет вопросов, которые «приватный или нет» уже не покрывает. Можно ли видеть профиль? Можно ли видеть твиты? Можно ли видеть лайки, истории, чужие подписки? Логика разрастается: для каждого типа контента — свои правила, для приватных аккаунтов — свои, для заблокированных пользователей — отдельные. Старый UserPrivacyService с этим уже не справляется (он умеет отвечать только на бинарный вопрос «приватный или нет»), и команда заводит рядом новый сервис — UserAccessService. По всем учебникам — single responsibility, разделение ответственности, всё как надо.

И ровно в этой точке UserAccessService упирается в неудобный вопрос. Чтобы ответить «может ли viewer смотреть твиты owner-а», он должен знать, подписан ли viewer на owner. А эта информация живёт в FollowsService. Значит, UserAccessService начинает зависеть от FollowsService.

@Injectable()
export class UserAccessService {
  constructor(
    private readonly followsService: FollowsService,
    private readonly userPrivacyService: UserPrivacyService,
  ) {}

  async canViewProfile(
    viewerId: string,
    ownerId: string,
  ): Promise<Result<boolean, UserAccessErrorCode>> {
    const isPrivateResult = await this.userPrivacyService.isPrivate(ownerId);
    if (isPrivateResult.isErr()) {
      return err(isPrivateResult.error);
    }
    if (!isPrivateResult.value) {
      return ok(true);
    }

    const isFollowingResult = await this.followsService.isFollowing(
      viewerId,
      ownerId,
    );
    if (isFollowingResult.isErr()) {
      return err(isFollowingResult.error);
    }
    return ok(isFollowingResult.value);
  }

  async canViewTweets(
    viewerId: string,
    ownerId: string,
  ): Promise<Result<boolean, UserAccessErrorCode>> {
    const isPrivateResult = await this.userPrivacyService.isPrivate(ownerId);
    if (isPrivateResult.isErr()) {
      return err(isPrivateResult.error);
    }
    if (!isPrivateResult.value) {
      return ok(true);
    }

    const isFollowingResult = await this.followsService.isFollowing(
      viewerId,
      ownerId,
    );
    if (isFollowingResult.isErr()) {
      return err(isFollowingResult.error);
    }

    return ok(isFollowingResult.value);
  }
}

У нас появился первый цикл. С первого взгляда — безобидная проблема: на уровне сервисов он ещё прячется за хорошим именованием.

UserAccessService:
  → FollowsService
  → UserPrivacyService

FollowsService:
  → UserPrivacyService

Все стрелки идут вперёд, ни одной обратной — формально красиво. А вот на уровне модулей картина уже зеркальная:

FollowsModule  ⇄  UsersModule

FollowsModule импортирует UsersModule ради UserPrivacyService. UsersModule импортирует FollowsModule ради FollowsService. Каждый импорт сам по себе абсолютно оправдан — любой ревьюер может пропустить и одобрить. Но вместе они образуют ловушку, которая захлопнется не сегодня и не завтра — а через два-три спринта, в случайный четверг, на чьём-то PR, который к самим этим импортам отношения не имеет.

А теперь то же самое в коде

Если развернуть всё в плоский набор @Module-декораторов, картина выглядит обманчиво просто. FollowsModule объявляет «мне нужен UsersModule», UsersModule — «мне нужен FollowsModule»:

@Module({
  imports: [UsersModule],
  providers: [FollowsService],
  exports: [FollowsService],
})
export class FollowsModule {}

@Module({
  imports: [FollowsModule],
  providers: [UserPrivacyService, UserAccessService],
  exports: [UserPrivacyService, UserAccessService],
})
export class UsersModule {}

Четыре импорта, два экспорта, никакого forwardRef, никаких трюков. Ни одна строчка не выглядит подозрительно — на каждой в любой команде вы получите approve за десять секунд. И именно этой пары imports достаточно, чтобы NestJS швырнул то самое Nest can't resolve dependencies.

Лекарство Nest даёт сам, прямо в документации — forwardRef. Оборачиваешь импорт в стрелочную функцию, и DI разрешает зависимость лениво, в момент использования, а не в момент сборки. Звучит как разрешение свыше: фреймворк не просто допускает, он рекомендует. Команда читает, кивает, патчит оба декоратора:

@Module({
  imports: [forwardRef(() => UsersModule)],
  providers: [FollowsService],
  exports: [FollowsService],
})
export class FollowsModule {}

@Module({
  imports: [forwardRef(() => FollowsModule)],
  providers: [UserPrivacyService, UserAccessService],
  exports: [UserPrivacyService, UserAccessService],
})
export class UsersModule {}

И вроде бы всё снова работает. Билд собирается, тесты идут, в логах тихо. На ревью два слова forwardRef пройдут так же легко, как и сами импорты — это же документированное API, всё легитимно. PR мерджится — и на полгода о цикле все дружно забывают.


А теперь — момент, когда forwardRef начинает мстить

Полгода спустя про цикл Users ↔ Follows уже никто не помнит. Жизнь идёт, фичи выкатываются, CI зелёный, новые разработчики видят forwardRef в imports и принимают его как часть стиля проекта — раз так делают везде, значит, так и надо.

И тут команда садится за новую фичу — ленту с комментами. У нас уже есть CommentsModule и ModerationModule. Угадайте, в каких отношениях. CommentsService ходит в ModerationService спросить «можно ли оставить этот коммент?» (анти-спам, бан, рейт-лимит). ModerationService ходит в CommentsService за последними N комментами юзера, чтобы посчитать спам-скор. Цикл? Цикл. forwardRef? forwardRef.

@Module({
  imports: [forwardRef(() => ModerationModule)],
  providers: [CommentsService],
  exports: [CommentsService],
})
export class CommentsModule {}

@Module({
  imports: [forwardRef(() => CommentsModule)],
  providers: [ModerationService],
  exports: [ModerationService],
})
export class ModerationModule {}

Теперь появляется FeedModule. Лента строится из постов, под каждым постом — комменты, и комменты от забаненных юзеров надо скрывать. Прямолинейный вариант — импортировать оба модуля:

@Module({
  imports: [CommentsModule, ModerationModule],
  providers: [FeedService],
})
export class FeedModule {}

И на этом можно было бы остановиться. Но тут срабатывает совершенно здоровый архитектурный инстинкт:

«Стоп, а почему Feed вообще должен знать про Moderation? Модерация — это деталь реализации Comments. Если завтра мы заменим её на ML-модель, Feed об этом узнавать не должен. Сделаем CommentsModule фасадом и реэкспортнём ModerationService через него».

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

@Module({
  imports: [forwardRef(() => ModerationModule)],
  providers: [CommentsService],
  exports: [CommentsService, ModerationService], // фасадим
})
export class CommentsModule {}

И тут Nest нам и говорит:

Nest can't resolve dependencies of the FeedService (?).
Please make sure that the argument ModerationService at index [0] is available in the FeedModule context.

Никакой forwardRef тут не поможет. Потому что forwardRef — это не модуль, это прокси-плейсхолдер, и его нельзя протащить транзитивно через exports. На момент сборки CommentsModule ModerationService для него ещё «не существует» в полноценном виде.

И начинается перебор вариантов, ни один из которых не выглядит привлекательно. Можно импортировать ModerationModule в Feed напрямую — но это отказ от фасада, и знание о модерации расползается на каждого, кто работает с комментами. Можно убрать реэкспорт и оставить два отдельных импорта — по сути то же самое, плюс ощущение, что архитектура «течёт». Можно разорвать цикл по-настоящему — переписать CommentsModule и ModerationModule, лезть в код, который трогали полгода назад и который никто уже толком не помнит; senior на стендапе обещает «давайте в следующем спринте», и спринт, понятно, не наступает. Можно, наконец, залить ещё forwardRef-ов сверху — иногда помогает, иногда нет, всегда хрупко, и в логах появляются красивые TypeError: Cannot read properties of undefined.

И вот это и есть настоящая цена forwardRef: он не решает проблему, он её замораживает. Нормальные паттерны он формально не запрещает — но как только вы пытаетесь их применить, он заставляет придумывать обходные костыли: то фасад нельзя сделать через реэкспорт — давайте через отдельный сервис-агрегатор; то агрегатор сам попадает в цикл — давайте дёргать его через ModuleRef.get() в рантайме; то в тестах половина графа не поднимается — давайте мокать руками. И вот на эти танцы вокруг старого цикла уходит львиная доля времени на каждой следующей фиче. Разработка превращается в попытки заставить это хотя бы собраться и запуститься, а не в продвижение продукта — даже если автор того цикла давно уволился, и в коде остался только его комментарий // TODO: распутать.

А теперь представьте, что так написано не один цикл, а 60% кодовой базы. Рефакторинг внутри уже невозможен в разумные сроки — любое изменение в одном модуле тянет за собой пять соседних через forwardRef-ы, эстимейт «два дня» превращается в «два спринта», результат заведомо непредсказуем. А никто и не хочет лезть в код, который трогали восемь лет назад и который никто уже не помнит — старое чинить психологически дороже, чем написать новое рядом. А «рядом» в сломанном монолите — это уже отдельный сервис. И вот тут команда находит «адекватный» выход: рубить функциональность на микросервисы. Не потому, что доменно так правильно, не потому, что нагрузка требует, а потому что монолит физически не собирается, а внутри его никто чинить не готов. Дальше — спроектировать транспорт между сервисами (gRPC? REST? Kafka? RabbitMQ?), развести синхронное и асинхронное взаимодействие, поднять балансировку и service discovery, развести CI/CD на отдельные пайплайны, написать контракт-тесты, обмазать всё ретраями, circuit breaker’ами и распределённым трейсингом, занять DevOps на квартал, переучить команду на сетевые ошибки. И всё это — чтобы скрыть комменты от забаненных юзеров. Идеальное соотношение «инвестиций в инфраструктуру» к «пользе для продукта»: миллион рублей в месяц на эксплуатацию ради одной галочки в чек-листе фичи.

Давайте даже посчитаем разницу в стоимости в маленькой команде. У вас продукт, в команде 2 бэкендера и 1 фронтендер. Возьмём усреднённую полную стоимость одного сеньор-бэкенда 5k мес со всеми налогами, страховкой и оборудованием. Два бэкендера за год = $120k.

В здоровом проекте на «борьбу с архитектурой» — дебаг билда, починку тестов, обходные манёвры — уходит 5–10% времени. В проекте, который полгода-год лежит под forwardRef-ами, — стабильно 30–50%. На дельте между этими двумя состояниями получается, что $30–60k в год буквально сжигаются на ровном месте, даже если ни одна фича за этот период не написана.

Но это только прямые расходы. Гораздо страшнее — opportunity cost. В здоровой кодовой базе ваша команда выкатывает фичу в неделю. С накопленными циклами та же команда выкатывает ту же фичу в три-четыре недели — не потому, что разрабы хуже, а потому что 70% спринта уходит на «почему не собирается / почему тесты не запускаются / как обойти цикл». За год это 9 месяцев недопоставленных фич. На рынке, где конкурент за это же время выкатил партнёрку, рекомендации и платную подписку, вас просто перестают рассматривать.

И вот здесь финальный аккорд. Тот самый первый forwardRef, который полгода назад сэкономил команде максимум 4 часа, по факту оплачивается $30–60k в год прямых расходов и девятью месяцами потерянного рынка. ROI того решения: –35 000% за первый год, –180 000% к пятому, –360 000% к десятому. Но никто его так не считает, потому что счёт приходит не сразу, а размазанным платежом по всем будущим спринтам — и винить вроде как уже некого: автор уволился, команда сменилась, а forwardRef стоит как стоял.

А теперь представьте, если это big tech или энтерпрайз. Команда не 2 бэкендера, а 50–200 человек на одном продукте. Полная стоимость одного синьора — уже $30–40k/мес со всеми бенефитами и L&D. Один продукт — $20–30M в год только на разработку. Доля времени на «борьбу с архитектурой» та же — 30–50%. На дельте это $6–15M в год буквально в трубу, без единой написанной фичи.

Плюс к этому появляется отдельная платформенная команда из 10–15 синьоров, чья единственная работа — помогать продуктовым командам обходить собственную архитектуру. Ещё $4–6M в год. Любая попытка крупной миграции — проект на 18–24 месяца с бюджетом $15–30M, в течение которого фичи не поставляются вообще. Конкурент за это время выкатывает три новых продуктовых линейки.

Дальше начинаются эффекты второго порядка. Синьоры, которые понимают, что чинить уже нельзя, уходят в течение года-двух — карьера здесь сводится к бесконечной борьбе с собственным наследством. На их место приходят люди, которым нужно полгода, чтобы понять, где можно прикасаться, а где нельзя. Внутри продукта расцветает «framework-война»: команды пилят свои локальные обёртки, лишь бы не трогать центральные модули. Любая M&A-сделка проседает по таймлайну вдвое — интегрировать купленную компанию в этот клубок физически невозможно. Технический долг превращается в корпоративный риск уровня совета директоров — и при этом всё ещё выглядит как «безобидный forwardRef, который кто-то поставил восемь лет назад».

Если коротко резюмировать: всё, что мы разобрали в этой части — от циклов и forwardRef-ов до миллионов в год и framework-войн — это симптомы одной и той же проблемы. У модулей нет внутренней структуры. Сервисы зовут сервисы, методы зовут методы, и любая попытка «правильно разделить» упирается в то, что разделять нечего — внутри модуля всё одинаково перемешано. Никто не написал плохого кода. Просто никто не написал и хорошего. В следующей части начнём с обратной стороны: что должно быть внутри модуля, чтобы такой цикл было физически невозможно построить.

А что же делать-то? Это пример был для понимания того, как слабо продуманная архитектура превратила код проекта в кота шрёдингера. Мы с вами поэтапно, пошагово создали систему, которая принесла потери бизнесу, time-to-market фичи в разы дольше, разработчики вместо разработки стабилизируют систему на соплях. Хотя начиналось всё хорошо.