惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
阮一峰的网络日志
阮一峰的网络日志
J
Java Code Geeks
宝玉的分享
宝玉的分享
C
CXSECURITY Database RSS Feed - CXSecurity.com
P
Privacy International News Feed
The Register - Security
The Register - Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
PCI Perspectives
PCI Perspectives
Hugging Face - Blog
Hugging Face - Blog
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
N
News | PayPal Newsroom
Google Online Security Blog
Google Online Security Blog
aimingoo的专栏
aimingoo的专栏
F
Full Disclosure
P
Palo Alto Networks Blog
A
About on SuperTechFans
Microsoft Azure Blog
Microsoft Azure Blog
F
Fortinet All Blogs
爱范儿
爱范儿
Recorded Future
Recorded Future
月光博客
月光博客
T
True Tiger Recordings
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Tenable Blog
L
Lohrmann on Cybersecurity
博客园 - 聂微东
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
大猫的无限游戏
大猫的无限游戏
S
Security @ Cisco Blogs
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
L
LINUX DO - 热门话题
Hacker News: Ask HN
Hacker News: Ask HN
C
Check Point Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
L
LangChain Blog
The Cloudflare Blog
Malwarebytes
Malwarebytes
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
I
InfoQ
N
Netflix TechBlog - Medium
Recent Announcements
Recent Announcements
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
SecWiki News
SecWiki News
云风的 BLOG
云风的 BLOG
T
ThreatConnect
博客园 - 叶小钗
B
Blog

Все публикации подряд на Хабре

Лучшие игры для Steam Deck в 2026 году по мнению пользователей Обход блокировок внутри iOS-приложения: VLESS + Reality через sing-box, и грабли по дороге [Перевод] Любой пользователь интернета может позвонить в вашу дверь Новый экспериментальный препарат для похудения обеспечил резкое снижение веса Хром и скорость Провалила вайтборд, но прошла тестовое — как я делала задание для Т-Банка Космическая линза помогла Уэббу увидеть древнейшую галактику Вселенной Почему custom URI schemes в Telegram Mini Apps ведут себя по-разному на Android, iOS и Desktop Как я сократил рутину QA до пары кликов: генератор API-тестов и тест-кейсов на LLM, которым хочу поделиться ИИ‑спасатель в кармане: как мы сделали агента для помощи при ЧС, который работает без интернета QNAME minimisation на практике: RFC 7816, реализация, грабли Агенты, роботы и мы: как ИИ перекраивает рынок труда в Европе От боли к npm install: TDLib для React-Native, или как я делал проект, а получилась библиотека Написание консольного симулятора баттл-арены на языке С++ с реализацией «умных» ботов Очень много букв… Или кейс по специфической настройке рабочего окружения Segmentation Fault: как оно устроено? Python в enterprise: момент, когда пора открыть Java не только ради собеседований MonoGame — игровой движок для тех, кто любит изобретать велосипеды Спасти рядового Буридана Рефакторинг выпадающих списков: от enum к конфигу-константе Free Porn Storage: передаём мемы в TLS-трафике, не привлекая внимания санитаров Мониторинг цен на Авито: MikroTik RouterOS Script Венесуэльская нефть после января 2026 Разговоры с ИИ Хотел упростить мониторинг проектов и в отпуск — пришлось обучать свой LLM. Часть 4. Тестирование Как вытащить ИТ из кризиса перегрузки, если найм запрещён Как мы подключили LLM к поддержке, а получили идеального лжеца Zero — новый agent-first язык программирования от Vercel, который изменит все (нет) Запускаем рекламу в дачной нише: какие креативы и форматы работают, на что смотреть в аналитике Паттерны организационного дизайна: практическое руководство Почему алгоритмы сливают твой депозит? 3 причины, о которых молчат «успешные» бэктесты Как «спят» вкладки в браузере Приоритет задач определяется не только ощущением срочности [Перевод] Махинации с прибылью Anthropic Project Loom: Virtual Threads, Scoped Values и preview #7 Structured Concurrency Мнения математиков о том, как ИИ опроверг гипотезу Эрдёша Слабоумие и отвага: как я за выходные сделала прототип ИИ-помощника для UX-дизайнера ИИ учит нас писать лучше. Или хуже? Как проектировать ИИ-инструменты, которые делают пользователей лучше «Раньше хотел каждый, сейчас и бесплатно не надо»: гаджеты, про которые мы все забыли ИИ-агенты в бизнесе: почему 80% компаний увольняют людей, но не получают ROI Как я строил ИИ-стартап, или Новые архитектурные риски 2026 4 интересных парадокса, рождающих жаркие дискуссии Рабочее место не-вайбкодера: настраиваем harness Когнитивный инжиниринг Feature Based Clean Architecture. Часть 1: Эволюция NestJS-приложения в неподдерживаемое состояние Как мы перестали бояться «пустых охватов» и сделали инфлюенс-маркетинг управляемым каналом роста Подключили B2B email-платформу к голосовым ассистентам через MCP. Архитектура, код, где ломается [Перевод] Почему AI-агенты ломаются на длинных задачах — и как обвязка помогает им дописывать приложения Облачно, возможны нейросети: кризис датасетов и ахиллесова пята систем машинного зрения — DIY-чтение на выходные Спустя 5 лет и $5 миллионов: почему создание нового языка для веб-разработки оказалось ошибкой Безопасная песочница Облачная LLM на 16 ГБ VRAM — часть 2: LangGraph Server, LangSmith и SDK Современный SSH-клиент для MS-DOS Как продвигать агентство недвижимости: от вывески до прямых эфиров MCP для GitHub + GitLab: инженерный гайд 2026 Вы платите OpenAI $20 в месяц, а он зарабатывает на вас ещё $100 млн за полтора месяца. И это только начало ИИ забирает работу «белых воротничков»: чему учить детей, чтобы выжить в будущем Практический ИИ-агент Python: LangGraph + Qdrant Как я делал ping и traceroute на iOS без entitlements — и почему это оказалось проще, чем UMP-консент для AdMob 4 MVP за 4 месяца, 30 холодных DM, 1 регистрация: building in public по-русски VPS-бастион: доступ к домашнему серверу без белого IP Kampus AI — нейросеть для генерации учебных работ для студентов и школьников Игры, помогающие продавать — примеры интересных рекламных акций с видеоиграми €500 в Telegram Ads принесли сделку на 350 000 ₽. Разбор B2B-кампании Чтение на выходные: «Разработка игр и теория развлечений» Рафа Костера Личный архив: сбор, бэкап, таймлайн фотографий INFOSTART TECH EVENT или INFOSTART A&PM EVENT — как понять, куда вам нужнее? Peer testing на основе Закона Линуса Релиз GitLab 19.0: ИИ-оркестрация, которая наконец-то догнала темп написания кода Как бизнесу оценить готовность к аттестации по новому Приказу ФСТЭК № 117 Технический гайд по сторис – часть 4: как мы добавили видео формат Представительство в арбитражном процессе: правовые различия между внешним защитником и инхаусом «Где новые фичи?» — Как AI-миграция легаси вернет IT-бюджет бизнесу Что нужно знать работнику про увольнение Новые требования Москвы к ЦИМ для АГР: готовый инструмент для проектировщиков в nanoCAD BIM Строительство WireGuard: простота и надёжность современного VPN-туннеля или секретное рукопожатие в тёмной комнате Выйдет ли GTA 6 в 2026 году, и чего ждать от игры Как меня назвали «невовлечённым», а я нашёл офшоры на Кипре Как LLM научила рекомендательную модель видеть больше, чем историю взаимодействий От хаоса к экосистеме: Модель зрелости комьюнити в бизнесе Свет, тьма, VEML7700 и Python Сказ о том, как мы процессы разработки в GRI меняли. Часть 2 Майский «В тренде VM»: громкие уязвимости в Linux, ActiveMQ, SharePoint и Acrobat Reader Статический анализ, заряженный ИИ: как LLM ищут уязвимости в коде и где их границы Блок “Процессы” и почему мы называем его нашим мини-n8n Как поменялся рынок интернет-рекламы: сравнение первых кварталов 2025 и 2026 годов: исследование click.ru Мониторинг Kerio Connect через Zabbix 7: разбор шаблона без агентов и regex по DAT 671 Allow в Claude Code за день: как родился сетап Spec-build 3 известные интересные задачи на логику Как айтишнику позаботиться о менталке и не перерабатывать OpenAI vs Anthropic: битва экс-коллег за корпоративного клиента и $1 трлн на IPO SEO для интернет-магазина в 2026: что поменялось и как с этим работать Сможете ли вы спроектировать Maven‑монорепозиторий для 5 микросервисов? 6 неудобных вопросов про американское произношение, которые айтишники боятся задать Неожиданная встреча: теория графов вновь помогла решить проблему в анализе Фурье Иллюзия трансформации: почему компании платят за спектакль вместо изменений AMD представила Ryzen 9 PRO 9965X3D и еще 5 процессоров, которые пойдут далеко не всем История IDE в Google Первые отзывы на новинки о System Design
Feature Based Clean Architecture. Часть 2: Декомпозиция на сервисы: анализ ограниченности подхода
shkvik · 2026-05-23 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение19 мин

Охват и читатели73

Аналитика

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

Краткий пересказ, чтобы не возвращаться к части 1. Мы оставили AuthService.signUp в состоянии, которое не нуждается в защите: двести строк в одной функции, шесть параметров на входе, четыре независимых домена бизнеса в одном методе и пять разных репозиториев в одной зависимости. И мы уже сформулировали, какой ответ возникает первым: разнести по сервисам — UsersService, ReferralsService, MarketingService, FraudService, PartnerService, — каждому свою зону ответственности; AuthService оставить оркестратором. Этот ответ — стандартный, признанный сообществом NestJS, и в любой команде его примут к рефакторингу без лишних дискуссий.

Часть 2 — про то, что произойдёт, когда команда этот рефакторинг честно сделает. Спойлер: код станет приятнее на глаз, файлов появится больше, метод signUp похудеет — и одновременно с этим всё, что было плохо в V3, останется плохо, просто в новой расфасовке. Чтобы это увидеть, нужно сначала пройти рефакторинг шаг за шагом, как его прошла бы любая нормальная команда.

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

AuthService (оркестрация)
│
├── UsersService        — создание пользователя, поиск по email, работа с данными пользователя
├── AntiFraudService    — проверки на абуз (IP, device, поведенческий скоринг)
├── ReferralService     — валидация рефералов, создание связей, лимиты и защита от злоупотреблений
├── PartnerService      — обработка партнёрских программ (блогеры, стримеры, партнёры) и расчёт дохода
├── BonusService        — начисление бонусов (реферальные, партнёрские, многоуровневые)
├── AnalyticsService    — запись событий (регистрация, эксперименты, конверсии, сегментация)
├── AdSourceService     — работа с источниками трафика (поиск, инкременты, A/B тесты)

Команда садится за рефакторинг с этим планом на руках. Тикет уходит в работу, обвешивается тестами, проходит ревью архитектора, и через несколько дней auth.service.ts оказывается примерно в таком виде.

AuthService.signUp V4

async signUp(
  email: string,
  password: string,
  referralCode?: string,
  adSourceCode?: string,
  ip?: string,
  deviceId?: string,
): Promise<SignUpResponse> {
  await this.antiFraudService.checkIp(ip);
  await this.antiFraudService.checkDevice(deviceId);
  await this.antiFraudService.checkBehavior(ip, deviceId);

  const adSource = adSourceCode
    ? await this.adSourceService.resolve(adSourceCode)
    : undefined;
  if (adSourceCode && !adSource) {
    throw new BadRequestException("Invalid ad source");
  }

  if (adSource) {
    await this.adSourceService.increment(adSource.id);
    await this.analyticsService.trackExperiment({
      source: adSource.code,
    });
  }

  const referral = referralCode
    ? await this.referralService.getByCode(referralCode)
    : undefined;
  if (referralCode && !referral) {
    throw new BadRequestException("Invalid referral code");
  }

  const partnerResult =
    referral && referral.influencerPartner
      ? await this.partnerService.processPartner(referral)
      : undefined;
  const referralOwner =
    referral && !referral.influencerPartner
      ? await this.referralService.validateReferral(referral, email)
      : undefined;

  const existingUserByEmail = await this.usersService.findByEmail(email);
  if (existingUserByEmail) {
    throw new BadRequestException("User already exists");
  }

  const newUser = await this.usersService.createUser({
    email,
    password,
    adSource,
    ip,
    deviceId,
  });

  if (referralOwner) {
    await this.bonusService.giveReferralBonus(referralOwner.id);
    await this.referralService.createReferral(referralOwner, newUser);
  }

  if (partnerResult) {
    await this.bonusService.givePartnerReward(
      partnerResult.ownerId,
      partnerResult.reward,
    );
    await this.analyticsService.trackPartnerReward(partnerResult);
  }

  await this.analyticsService.trackRegistration({
    userId: newUser.id,
    source: adSource?.code,
    ip,
  });

  return {
    id: newUser.id,
    email: newUser.email,
  };
}

Оговорка про обработку ошибок. Дальше в коде вы увидите, что сервисы, на которые опирается signUp (все те, что мы только что вынесли), начинают возвращать не брошенные исключения, а явный Result<T, E>. Это объект, который рассказывает о результате операции через метод .isErr() и доступ к .value или .error. Изменение сознательное: каждый внутренний сервис обрабатывает ошибки как часть контракта функции, а вызывающая сторона видит весь набор возможных исходов прямо в типе. Сам signUp остаётся точкой границы между бизнес-логикой и HTTP-транспортом — он принимает Result от каждого вызова и на месте конвертирует ошибки в подходящий HttpException, потому что NestJS-фильтр на HTTP-уровне ожидает именно их. Такое разделение удобно тем, что Result-стиль и throw-стиль больше не конкурируют: внутри сервисов — Result, на границе AuthService — конкретный BadRequestException / ConflictException / ForbiddenException / InternalServerErrorException, который NestJS превратит в нужный HTTP-код. Конкретная реализация Result — вопрос предпочтения. Я использую монаду, потому что мне на длинной дистанции с ней удобнее: компилятор заставляет проговорить каждый исход. Всё, что будет показано ниже, одинаково реализуемо через discriminated unions, любую библиотеку с похожей семантикой или классические try/catch — архитектурный смысл от этого не меняется. Если хочется посмотреть индустриальный стандарт такого подхода в TypeScript — это библиотека neverthrow, я в коде использую именно её API. Замечу заранее: переход на Result сам по себе ничего не лечит в архитектуре — он только делает ошибки видимыми. Всё структурное, что мы обсуждали, остаётся на своих местах. Просто теперь оно перестанет прятаться за throw-ами в глубине вызовов.

AuthService.signUp V5

async signUp(
  email: string,
  password: string,
  referralCode?: string,
  adSourceCode?: string,
  ip?: string,
  deviceId?: string,
): Promise<SignUpResponse> {
  const checkIpResult = await this.antiFraudService.checkIp(ip);
  if (checkIpResult.isErr()) {
    throw new ForbiddenException("SIGN_UP_ANTI_FRAUD_REJECTED");
  }

  const checkDeviceResult = await this.antiFraudService.checkDevice(deviceId);
  if (checkDeviceResult.isErr()) {
    throw new ForbiddenException("SIGN_UP_ANTI_FRAUD_REJECTED");
  }

  const checkBehaviorResult = await this.antiFraudService.checkBehavior(
    ip,
    deviceId,
  );
  if (checkBehaviorResult.isErr()) {
    throw new ForbiddenException("SIGN_UP_ANTI_FRAUD_REJECTED");
  }

  const resolveAdSourceResult = adSourceCode
    ? await this.adSourceService.resolve(adSourceCode)
    : ok(undefined);

  if (resolveAdSourceResult.isErr()) {
    throw new BadRequestException("SIGN_UP_INVALID_AD_SOURCE");
  }
  const adSource = resolveAdSourceResult.value;

  if (adSource) {
    const incrementAdSourceResult = await this.adSourceService.increment(
      adSource.id,
    );

    if (incrementAdSourceResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }

    const trackExperimentResult = await this.analyticsService.trackExperiment(
      { source: adSource.code },
    );

    if (trackExperimentResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }
  }

  const getReferralResult = referralCode
    ? await this.referralService.getByCode(referralCode)
    : ok(undefined);

  if (getReferralResult.isErr()) {
    throw new BadRequestException("SIGN_UP_INVALID_REFERRAL_CODE");
  }
  const referral = getReferralResult.value;

  const processPartnerResult =
    referral && referral.influencerPartner
      ? await this.partnerService.processPartner(referral)
      : ok(undefined);

  if (processPartnerResult.isErr()) {
    throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
  }

  const partnerResult = processPartnerResult.value;

  const validateReferralResult =
    referral && !referral.influencerPartner
      ? await this.referralService.validateReferral(referral, email)
      : ok(undefined);

  if (validateReferralResult.isErr()) {
    throw new BadRequestException("SIGN_UP_REFERRAL_VALIDATION_FAILED");
  }

  const referralOwner = validateReferralResult.value;

  const findUserResult = await this.usersService.findByEmail(email);

  if (findUserResult.isErr()) {
    throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
  }

  if (findUserResult.value) {
    throw new ConflictException("SIGN_UP_USER_ALREADY_EXISTS");
  }

  const createUserResult = await this.usersService.createUser({
    email,
    password,
    adSource,
    ip,
    deviceId,
  });
  if (createUserResult.isErr()) {
    if (createUserResult.error === "CREATE_USER_CONFLICT") {
      throw new ConflictException("SIGN_UP_USER_ALREADY_EXISTS");
    }

    throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
  }
  const newUser = createUserResult.value;

  if (referralOwner) {
    const giveReferralBonusResult =
      await this.bonusService.giveReferralBonus(referralOwner.id);

    if (giveReferralBonusResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }

    const createReferralResult = await this.referralService.createReferral(
      referralOwner,
      newUser,
    );
    if (createReferralResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }
  }

  if (partnerResult) {
    const givePartnerRewardResult =
      await this.bonusService.givePartnerReward(
        partnerResult.ownerId,
        partnerResult.reward,
      );

    if (givePartnerRewardResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }

    const trackPartnerRewardResult =
      await this.analyticsService.trackPartnerReward(partnerResult);

    if (trackPartnerRewardResult.isErr()) {
      throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
    }
  }

  const trackRegistrationResult =
    await this.analyticsService.trackRegistration({
      userId: newUser.id,
      source: adSource?.code,
      ip,
    });

  if (trackRegistrationResult.isErr()) {
    throw new InternalServerErrorException("SIGN_UP_INTERNAL_ERROR");
  }

  return {
    id: newUser.id,
    email: newUser.email,
  };
}

Эту версию команда показывает на демо. На уровне AuthService.signUp всё действительно так, как и задумывалось: каждая зависимость занимает свою зону ответственности, оркестрация осталась тонкой, в коде можно ткнуть пальцем и сразу увидеть, где живёт анти-фрод, где партнёры, где аналитика. Архитектор кивает, ревью закрывается за пятнадцать минут. Но архитектурная ловушка лежит не в AuthService.signUp — и никогда там не лежала. Чтобы её увидеть, нужно перестать смотреть на оркестратор и открыть один из тех сервисов, которые мы только что аккуратно вынесли. Возьмём первый по порядку — UsersService.

Эволюция модуля «Users»

Параллельно с тем, как усложнялась регистрация, сам модуль users тоже не стоял на месте. Фронту требовались методы для отображения и редактирования профиля, аналитике — счётчики и срезы, маркетингу — атрибуты пользователей и сегментация, поддержке — административные операции. То, что в начале статьи было одним маленьким модулем с одной таблицей, к этому моменту превратилось в самостоятельный домен с собственным набором use-case’ов. К текущему этапу UsersService отвечает уже минимум за следующее:

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

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

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

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

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

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

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

src/modules/users/
├── users.module.ts
├── users.service.ts
├── users.controller.ts
├── dto/
│   ├── get-profile.dto.ts
│   ├── update-profile.dto.ts
│   ├── update-account-settings.dto.ts
│   ├── update-privacy.dto.ts
│   ├── update-preferences.dto.ts
│   ├── get-user-stats.dto.ts
│   └── me.dto.ts
└── entities/
    ├── user.entity.ts
    ├── user-profile.entity.ts
    ├── user-settings.entity.ts
    ├── user-privacy.entity.ts
    ├── user-preferences.entity.ts
    ├── user-stats.entity.ts
    └── user-session.entity.ts

Под этот набор сценариев у модуля появился собственный контроллер, в котором каждому use-case’у отвечает отдельная ручка. Контроллер на этом этапе выглядит так:

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(":id/profile")
  async getProfile(@Param() dto: GetProfileDto): Promise<UserProfileResponse> {
    return this.usersService.getProfile(dto.userId);
  }

  @Patch(":id/profile")
  async updateProfile(
    @Param() params: GetProfileDto,
    @Body() dto: UpdateProfileDto,
  ): Promise<UserProfileResponse> {
    return this.usersService.updateProfile(params.userId, dto);
  }

  @Patch(":id/settings")
  async updateAccountSettings(
    @Param() params: GetProfileDto,
    @Body() dto: UpdateAccountSettingsDto,
  ): Promise<UserAccountSettingsResponse> {
    return this.usersService.updateAccountSettings(params.userId, dto);
  }

  @Patch(":id/privacy")
  async updatePrivacy(
    @Param() params: GetProfileDto,
    @Body() dto: UpdatePrivacyDto,
  ): Promise<UserPrivacyResponse> {
    return this.usersService.updatePrivacy(params.userId, dto);
  }

  @Patch(":id/preferences")
  async updatePreferences(
    @Param() params: GetProfileDto,
    @Body() dto: UpdatePreferencesDto,
  ): Promise<UserPreferencesResponse> {
    return this.usersService.updatePreferences(params.userId, dto);
  }

  @Get(":id/stats")
  async getUserStats(
    @Param() dto: GetUserStatsDto,
  ): Promise<UserStatsResponse> {
    return this.usersService.getUserStats(dto.userId);
  }

  @Get("me")
  async getMe(@Req() req: Request): Promise<CurrentUserResponse> {
    return this.usersService.getCurrentUser(req.user.id);
  }
}

На уровне контроллера структура выглядит образцово: семь ручек — семь зон ответственности, каждая с собственным DTO, ни одна не путается с другой. Возникает естественный вопрос — а что в этот момент происходит с сервисом, на который контроллер опирается? Логичное ожидание такое: раз контроллер аккуратно разнесён по use-case’ам, то и UsersService должен зеркально повторять эту структуру — отдельный метод на каждый сценарий, отдельная зона ответственности, такая же дисциплина внутри. Так это устроено в большинстве учебных примеров и так это рекомендуется в документации NestJS. Откроем users.service.ts и посмотрим, что в реальном проекте оказалось вместо ожидания.

UsersService V1

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(UserProfile)
    private readonly profileRepository: Repository<UserProfile>,
    @InjectRepository(UserSettings)
    private readonly settingsRepository: Repository<UserSettings>,
    @InjectRepository(UserPrivacy)
    private readonly privacyRepository: Repository<UserPrivacy>,
    @InjectRepository(UserPreferences)
    private readonly preferencesRepository: Repository<UserPreferences>,
    @InjectRepository(UserStats)
    private readonly statsRepository: Repository<UserStats>,
  ) {}

  async findByEmail(
    email: string,
  ): Promise<Result<User | undefined, FindUserErrorCode>> {
    const findUserResult = await fromAsyncThrowable(async () =>
      this.userRepository.findOne({ where: { email } }),
    )();

    if (findUserResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }

    return ok(findUserResult.value ?? undefined);
  }

  async createUser(
    data: CreateUserData,
  ): Promise<Result<User, CreateUserErrorCode>> {
    const newUser = this.userRepository.create({
      email: data.email,
      password: data.password,
      registrationIp: data.ip,
      deviceId: data.deviceId,
      adSource: data.adSource,
      isVerified: false,
    });

    const saveUserResult = await fromAsyncThrowable(async () =>
      this.userRepository.save(newUser),
    )();

    if (saveUserResult.isErr()) {
      if (isUniqueQueryError(saveUserResult.error)) {
        return err("CREATE_USER_CONFLICT");
      }
      return err("CREATE_USER_DATABASE_ERROR");
    }

    const initUserRelationsResult = await fromAsyncThrowable(async () =>
      Promise.all([
        this.profileRepository.save({ userId: newUser.id }),
        this.settingsRepository.save({ userId: newUser.id }),
        this.privacyRepository.save({ userId: newUser.id }),
        this.preferencesRepository.save({ userId: newUser.id }),
        this.statsRepository.save({ userId: newUser.id }),
      ]),
    )();

    if (initUserRelationsResult.isErr()) {
      return err("CREATE_USER_DATABASE_ERROR");
    }

    return ok(newUser);
  }

  async getProfile(userId: string): Promise<UserProfile> {
    const profile = await this.profileRepository.findOne({ where: { userId } });
    if (!profile) {
      throw new NotFoundException("USER_PROFILE_NOT_FOUND");
    }
    return profile;
  }

  async updateProfile(
    userId: string,
    dto: UpdateProfileDto,
  ): Promise<UserProfile> {
    await this.profileRepository.update({ userId }, dto);
    return this.getProfile(userId);
  }

  async updateAccountSettings(
    userId: string,
    dto: UpdateAccountSettingsDto,
  ): Promise<UserSettings> {
    await this.settingsRepository.update({ userId }, dto);

    const settings = await this.settingsRepository.findOne({
      where: { userId },
    });
    if (!settings) {
      throw new NotFoundException("USER_SETTINGS_NOT_FOUND");
    }
    return settings;
  }

  async updatePrivacy(
    userId: string,
    dto: UpdatePrivacyDto,
  ): Promise<UserPrivacy> {
    await this.privacyRepository.update({ userId }, dto);

    const privacy = await this.privacyRepository.findOne({
      where: { userId },
    });
    if (!privacy) {
      throw new NotFoundException("USER_PRIVACY_NOT_FOUND");
    }
    return privacy;
  }

  async updatePreferences(
    userId: string,
    dto: UpdatePreferencesDto,
  ): Promise<UserPreferences> {
    await this.preferencesRepository.update({ userId }, dto);

    const preferences = await this.preferencesRepository.findOne({
      where: { userId },
    });
    if (!preferences) {
      throw new NotFoundException("USER_PREFERENCES_NOT_FOUND");
    }
    return preferences;
  }

  async getUserStats(userId: string): Promise<UserStats> {
    const stats = await this.statsRepository.findOne({ where: { userId } });
    if (!stats) {
      throw new NotFoundException("USER_STATS_NOT_FOUND");
    }
    return stats;
  }

  async getCurrentUser(userId: string): Promise<CurrentUserResponse> {
    const [profile, settings, privacy, preferences, stats] = await Promise.all([
      this.profileRepository.findOne({ where: { userId } }),
      this.settingsRepository.findOne({ where: { userId } }),
      this.privacyRepository.findOne({ where: { userId } }),
      this.preferencesRepository.findOne({ where: { userId } }),
      this.statsRepository.findOne({ where: { userId } }),
    ]);

    if (!profile || !settings || !privacy || !preferences || !stats) {
      throw new NotFoundException("USER_NOT_FOUND");
    }

    return { profile, settings, privacy, preferences, stats };
  }
}

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

AuthService уже здесь — он зашёл первым, в момент регистрации, мы это видели в signUp V4/V5. Контроллер тоже здесь — он отдаёт пользователю его собственные данные. В течение ближайших нескольких спринтов в эту очередь встанут почти все остальные модули продукта. Feed захочет знать, на кого пользователь подписан и кому он разрешает читать себя. Notifications — куда отправлять push, включены ли уведомления и не заблокирован ли получатель. Comments, Likes и Follows — что пользователь существует, что он не приватный (или что зритель на него подписан), плюс username и avatar для отображения. Search — фильтровать выдачу по приватности и отдавать профиль. Media — проверять права на загрузку. Moderation и анти-фрод — статус, поведение, историю действий. И все эти запросы — все, без исключения — приземлятся в один и тот же файл.

UsersService V2

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
    @InjectRepository(UserProfile)
    private readonly profileRepository: Repository<UserProfile>,
    @InjectRepository(UserSettings)
    private readonly settingsRepository: Repository<UserSettings>,
    @InjectRepository(UserPrivacy)
    private readonly privacyRepository: Repository<UserPrivacy>,
    @InjectRepository(UserPreferences)
    private readonly preferencesRepository: Repository<UserPreferences>,
    @InjectRepository(UserStats)
    private readonly statsRepository: Repository<UserStats>,
  ) {}

  async findByEmail(
    email: string,
  ): Promise<Result<User | undefined, FindUserErrorCode>> {
    const findUserResult = await fromAsyncThrowable(async () =>
      this.userRepository.findOne({ where: { email } }),
    )();
    if (findUserResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }
    return ok(findUserResult.value ?? undefined);
  }

  async createUser(
    data: CreateUserData,
  ): Promise<Result<User, CreateUserErrorCode>> {
    const newUser = this.userRepository.create({
      email: data.email,
      password: data.password,
      registrationIp: data.ip,
      deviceId: data.deviceId,
      adSource: data.adSource,
      isVerified: false,
    });

    const saveUserResult = await fromAsyncThrowable(async () =>
      this.userRepository.save(newUser),
    )();
    if (saveUserResult.isErr()) {
      if (isUniqueQueryError(saveUserResult.error)) {
        return err("CREATE_USER_CONFLICT");
      }
      return err("CREATE_USER_DATABASE_ERROR");
    }

    const initUserRelationsResult = await fromAsyncThrowable(async () =>
      Promise.all([
        this.profileRepository.save({ userId: newUser.id }),
        this.settingsRepository.save({ userId: newUser.id }),
        this.privacyRepository.save({ userId: newUser.id }),
        this.preferencesRepository.save({ userId: newUser.id }),
        this.statsRepository.save({ userId: newUser.id }),
      ]),
    )();
    if (initUserRelationsResult.isErr()) {
      return err("CREATE_USER_DATABASE_ERROR");
    }

    return ok(newUser);
  }

  async exists(userId: string): Promise<Result<boolean, FindUserErrorCode>> {
    const checkExistsResult = await fromAsyncThrowable(async () =>
      this.userRepository.exist({ where: { id: userId } }),
    )();
    if (checkExistsResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }
    return ok(checkExistsResult.value);
  }

  async getProfile(userId: string): Promise<UserProfile> {
    const profile = await this.profileRepository.findOne({ where: { userId } });
    if (!profile) {
      throw new NotFoundException("USER_PROFILE_NOT_FOUND");
    }
    return profile;
  }

  async updateProfile(
    userId: string,
    dto: UpdateProfileDto,
  ): Promise<UserProfile> {
    await this.profileRepository.update({ userId }, dto);
    return this.getProfile(userId);
  }

  async updateAccountSettings(
    userId: string,
    dto: UpdateAccountSettingsDto,
  ): Promise<UserSettings> {
    await this.settingsRepository.update({ userId }, dto);

    const settings = await this.settingsRepository.findOne({
      where: { userId },
    });
    if (!settings) {
      throw new NotFoundException("USER_SETTINGS_NOT_FOUND");
    }
    return settings;
  }

  async updatePrivacy(
    userId: string,
    dto: UpdatePrivacyDto,
  ): Promise<UserPrivacy> {
    await this.privacyRepository.update({ userId }, dto);

    const privacy = await this.privacyRepository.findOne({
      where: { userId },
    });
    if (!privacy) {
      throw new NotFoundException("USER_PRIVACY_NOT_FOUND");
    }
    return privacy;
  }

  async updatePreferences(
    userId: string,
    dto: UpdatePreferencesDto,
  ): Promise<UserPreferences> {
    await this.preferencesRepository.update({ userId }, dto);

    const preferences = await this.preferencesRepository.findOne({
      where: { userId },
    });
    if (!preferences) {
      throw new NotFoundException("USER_PREFERENCES_NOT_FOUND");
    }
    return preferences;
  }

  async getUserStats(userId: string): Promise<UserStats> {
    const stats = await this.statsRepository.findOne({ where: { userId } });
    if (!stats) {
      throw new NotFoundException("USER_STATS_NOT_FOUND");
    }
    return stats;
  }

  async getCurrentUser(userId: string): Promise<CurrentUserResponse> {
    const [profile, settings, privacy, preferences, stats] = await Promise.all([
      this.profileRepository.findOne({ where: { userId } }),
      this.settingsRepository.findOne({ where: { userId } }),
      this.privacyRepository.findOne({ where: { userId } }),
      this.preferencesRepository.findOne({ where: { userId } }),
      this.statsRepository.findOne({ where: { userId } }),
    ]);

    if (!profile || !settings || !privacy || !preferences || !stats) {
      throw new NotFoundException("USER_NOT_FOUND");
    }

    return { profile, settings, privacy, preferences, stats };
  }

  async getFollowingIds(
    userId: string,
  ): Promise<Result<string[], FindUserErrorCode>> {
    return ok([]);
  }

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

    const getFollowingResult = await this.getFollowingIds(viewerId);
    if (getFollowingResult.isErr()) {
      return err(getFollowingResult.error);
    }
    return ok(getFollowingResult.value.includes(ownerId));
  }

  async canReceiveNotification(
    userId: string,
    type: string,
  ): Promise<Result<boolean, FindUserErrorCode>> {
    const findSettingsResult = await this.findUserSettings(userId);
    if (findSettingsResult.isErr()) {
      return err(findSettingsResult.error);
    }

    const settings = findSettingsResult.value;
    if (!settings) return ok(false);

    if (type === "email") return ok(settings.emailNotifications);
    if (type === "push") return ok(settings.pushNotifications);
    return ok(false);
  }

  async getPublicUserInfo(
    userId: string,
  ): Promise<Result<UserPublicInfo, FindUserErrorCode>> {
    const findProfileResult = await fromAsyncThrowable(async () =>
      this.profileRepository.findOne({ where: { userId } }),
    )();
    if (findProfileResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }

    return ok({
      id: userId,
      username: findProfileResult.value?.username,
      avatarUrl: findProfileResult.value?.avatarUrl,
    });
  }

  async isSearchable(
    userId: string,
  ): Promise<Result<boolean, FindUserErrorCode>> {
    const findPrivacyResult = await this.findUserPrivacy(userId);
    if (findPrivacyResult.isErr()) {
      return err(findPrivacyResult.error);
    }
    return ok(!findPrivacyResult.value?.isPrivate);
  }

  async isUserBlocked(
    userId: string,
  ): Promise<Result<boolean, FindUserErrorCode>> {
    return ok(false);
  }

  async getUserStatus(
    userId: string,
  ): Promise<Result<UserStatus | undefined, FindUserErrorCode>> {
    const findUserResult = await fromAsyncThrowable(async () =>
      this.userRepository.findOne({
        where: { id: userId },
        select: ["id", "isVerified"],
      }),
    )();
    if (findUserResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }
    return ok(findUserResult.value ?? undefined);
  }

  private async findUserSettings(
    userId: string,
  ): Promise<Result<UserSettings | undefined, FindUserErrorCode>> {
    const findSettingsResult = await fromAsyncThrowable(async () =>
      this.settingsRepository.findOne({ where: { userId } }),
    )();
    if (findSettingsResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }
    return ok(findSettingsResult.value ?? undefined);
  }

  private async findUserPrivacy(
    userId: string,
  ): Promise<Result<UserPrivacy | undefined, FindUserErrorCode>> {
    const findPrivacyResult = await fromAsyncThrowable(async () =>
      this.privacyRepository.findOne({ where: { userId } }),
    )();
    if (findPrivacyResult.isErr()) {
      return err("FIND_USER_DATABASE_ERROR");
    }
    return ok(findPrivacyResult.value ?? undefined);
  }

  private async isPrivate(
    userId: string,
  ): Promise<Result<boolean, FindUserErrorCode>> {
    const findPrivacyResult = await this.findUserPrivacy(userId);
    if (findPrivacyResult.isErr()) {
      return err(findPrivacyResult.error);
    }
    return ok(!!findPrivacyResult.value?.isPrivate);
  }
}

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

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

  • настройки аккаунта

  • приватность

  • предпочтения

  • статистика

  • проверки для других модулей

План декомпозиции получается ровно такой же по форме, как тот, что мы делали для AuthService страниц назад:

src/modules/users/
├── users.module.ts
├── users.controller.ts
│
├── services/
│   ├── users.service.ts
│   ├── user-profile.service.ts
│   ├── user-settings.service.ts
│   ├── user-privacy.service.ts
│   ├── user-preferences.service.ts
│   ├── user-stats.service.ts
│   └── user-access.service.ts
│
├── dto/
│   ├── get-profile.dto.ts
│   ├── update-profile.dto.ts
│   ├── update-account-settings.dto.ts
│   ├── update-privacy.dto.ts
│   ├── update-preferences.dto.ts
│   ├── get-user-stats.dto.ts
│   └── me.dto.ts
│
└── entities/
    ├── user.entity.ts
    ├── user-profile.entity.ts
    ├── user-settings.entity.ts
    ├── user-privacy.entity.ts
    ├── user-preferences.entity.ts
    ├── user-stats.entity.ts
    └── user-session.entity.ts

Теперь вроде бы стало лучше:

  • UserProfileService отвечает за профиль

  • UserSettingsService отвечает за настройки

  • UserPrivacyService отвечает за приватность

  • UserPreferencesService отвечает за предпочтения

  • UserStatsService отвечает за статистику

  • UserAccessService отвечает за проверки доступа

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

И именно в этот момент в команду прилетает следующий тикет, в котором всё это начнёт ломаться. Никаких архитектурных переворотов — просто ещё одна обычная фича, день работы. Разберём.