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

推荐订阅源

T
Threatpost
aimingoo的专栏
aimingoo的专栏
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
T
Tailwind CSS Blog
J
Java Code Geeks
博客园_首页
Google Online Security Blog
Google Online Security Blog
Hugging Face - Blog
Hugging Face - Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
I
Intezer
P
Palo Alto Networks Blog
V
Vulnerabilities – Threatpost
雷峰网
雷峰网
O
OpenAI News
SecWiki News
SecWiki News
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
美团技术团队
N
News | PayPal Newsroom
Project Zero
Project Zero
Forbes - Security
Forbes - Security
IT之家
IT之家
A
Arctic Wolf
WordPress大学
WordPress大学
Jina AI
Jina AI
T
Tor Project blog
博客园 - 三生石上(FineUI控件)
S
Secure Thoughts
Google DeepMind News
Google DeepMind News
Attack and Defense Labs
Attack and Defense Labs
博客园 - 聂微东
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Privacy International News Feed
Cloudbric
Cloudbric
G
GRAHAM CLULEY
博客园 - 叶小钗
H
Hacker News: Front Page
腾讯CDC
量子位
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
C
Cyber Attacks, Cyber Crime and Cyber Security
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
宝玉的分享
宝玉的分享
爱范儿
爱范儿
L
Lohrmann on Cybersecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
C
CERT Recently Published Vulnerability Notes

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

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
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 отвечает за проверки доступа

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

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