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

推荐订阅源

N
News | PayPal Newsroom
P
Proofpoint News Feed
Cyberwarzone
Cyberwarzone
C
Cisco Blogs
SecWiki News
SecWiki News
Know Your Adversary
Know Your Adversary
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Vercel News
Vercel News
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
罗磊的独立博客
NISL@THU
NISL@THU
WordPress大学
WordPress大学
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Threat Research - Cisco Blogs
AI
AI
Simon Willison's Weblog
Simon Willison's Weblog
Security Archives - TechRepublic
Security Archives - TechRepublic
有赞技术团队
有赞技术团队
L
LINUX DO - 热门话题
Hacker News: Ask HN
Hacker News: Ask HN
V
V2EX
G
GRAHAM CLULEY
TaoSecurity Blog
TaoSecurity Blog
Hugging Face - Blog
Hugging Face - Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
F
Fortinet All Blogs
博客园 - 叶小钗
博客园 - 三生石上(FineUI控件)
云风的 BLOG
云风的 BLOG
Recorded Future
Recorded Future
Latest news
Latest news
The Hacker News
The Hacker News
aimingoo的专栏
aimingoo的专栏
T
Troy Hunt's Blog
S
Schneier on Security
I
Intezer
Google DeepMind News
Google DeepMind News
A
Arctic Wolf
Apple Machine Learning Research
Apple Machine Learning Research
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Threatpost
爱范儿
爱范儿
The Register - Security
The Register - Security
S
SegmentFault 最新的问题
Blog — PlanetScale
Blog — PlanetScale
博客园 - 聂微东
宝玉的分享
宝玉的分享
Recent Commits to openclaw:main
Recent Commits to openclaw:main
美团技术团队
B
Blog RSS Feed

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

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет 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. Часть 1: Эволюция NestJS-приложения в неподдерживаемое состояние
shkvik · 2026-05-22 · via Все публикации подряд на Хабре

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

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

Охват и читатели7.4K

Аналитика

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

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

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

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

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

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

Эта статья — разбор того, как типичный бэкенд на NestJS деградирует с ростом функционала и как идеи Clean Architecture позволяют этого избежать. Я пройду по полному циклу: покажу на примере «до и после», как feature-based-структура, которую сегодня продвигают как стандарт, теряет управляемость с масштабом; разберу типичные сценарии деградации; оценю в деньгах и человеко-часах, во что они обходятся бизнесу; объясню, почему именно такая кодовая база заставляет команды дробить монолит на микросервисы задолго до того, как это оправдано. После этого я предложу подход, направленный против деградации, — и приведу для него формальное математическое обоснование. К концу статьи у вас будет и аргументация, и инструменты, чтобы применить этот подход к своим системам.

В качестве сквозного примера возьмём задачу, которую разбирают на System Design-собеседованиях из раза в раз: бэкенд для сервиса класса Twitter. Минимальный набор инструментов очевиден — база данных и приложение. Вопросы предельной производительности, шардирования и горизонтального масштабирования мы сознательно вынесем за скобки: статья про структуру кода, а не про пропускную способность. Стек зафиксируем сразу — Node.js и фреймворк NestJS. Начнём с того, что выпишем функциональные требования к системе.

  1. Регистрация и авторизация

  2. Создание твита

  3. Лента (feed)

  4. Подписки (follow / unfollow)

  5. Профиль пользователя

  6. Лайки

  7. Ретвиты

  8. Комментарии (replies)

  9. Поиск (пользователей, твитов, хэштегов)

  10. Уведомления (лайки, подписки, ответы)

  11. Медиа (изображения / видео)

Очевидно, что реальный Twitter устроен на порядок сложнее и строился годами командой в сотни инженеров — но цель статьи не в том, чтобы воспроизвести продукт, а в том, чтобы рассмотреть архитектурную эволюцию на знакомой предметной области. Список фич зафиксирован, схема базы и набор эндпоинтов на этом этапе вырисовываются практически без раздумий, стек выбран. Открываем документацию NestJS — и с первой же страницы документация предлагает нам опорную структуру проекта.

src/
├── main.ts
├── app.module.ts
│
├── modules/
│   ├── auth/
│   ├── users/
│   ├── tweets/
│   ├── feed/
│   ├── likes/
│   ├── comments/
│   ├── retweets/
│   ├── follows/
│   ├── notifications/
│   ├── search/
│   └── media/
│
├── common/
│   ├── guards/
│   ├── interceptors/
│   ├── filters/
│   ├── decorators/
│   └── utils/
│
├── database/
│   ├── prisma/ или typeorm/
│   └── migrations/
│
├── config/
│   └── configuration.ts

Помимо структуры верхнего уровня, документация описывает и рекомендованный состав одного модуля — какие файлы и в каком порядке имеет смысл создавать. Эта рекомендация одинакова для модулей с разной природой: и auth, и tweets, и search собираются по одному и тому же шаблону. Покажу на примере модуля tweets:

src/modules/tweets/
├── tweets.module.ts
├── tweets.controller.ts
├── tweets.service.ts
├── dto/
│   └── create-tweet.dto.ts
├── entities/
│   └── tweet.entity.ts

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

Минимально необходимый функционал: две ручки — регистрация и вход.

@Controller("auth")
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post("sign-up")
  async signUp(@Body() dto: SignUpDto): Promise<SignUpResponse> {
    return this.authService.signUp(dto.email, dto.password);
  }

  @Post("sign-in")
  async signIn(@Body() dto: SignInDto): Promise<SignInResponse> {
    return this.authService.signIn(dto.email, dto.password);
  }
}

В качестве ORM по ходу статьи будем использовать TypeORM — выбор не принципиален для разговора об архитектуре, и всё, что ниже, легко переносится на Prisma, MikroORM или Drizzle. Просто нужен инструмент, на котором удобно показывать запросы. Сначала опишем сущность пользователя.

@Entity("users")
export class User {
  @PrimaryGeneratedColumn("uuid")
  id: string;

  @Column({ unique: true })
  email: string;

  @Column()
  password: string;

  @CreateDateColumn()
  createdAt: Date;
}

Согласно принятой структуре, код, относящийся к работе с пользователями, должен жить в src/modules/users. Значит, и логика создания записи о пользователе в базе данных формально принадлежит этому модулю. Это уже неплохая дисциплина — лучше, чем вставлять SQL-запросы прямо в AuthService. Но в этой точке у разработчика реально два варианта: обращаться к репозиторию пользователей напрямую из AuthService или ходить через UsersService. На маленьком масштабе оба варианта работают и оба проходят ревью — поэтому сначала рассмотрим их рядом.

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private readonly usersRepository: Repository<User>,
  ) {}

  async signUp(email: string, password: string) {
    const existing = await this.usersRepository.findOne({
      where: { email },
    });

    if (existing) {
      throw new Error("User already exists");
    }

    const user = this.usersRepository.create({
      email,
      password,
    });

    await this.usersRepository.save(user);

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

  async signIn(email: string, password: string) {
    const user = await this.usersRepository.findOne({
      where: { email },
    });

    if (!user) {
      throw new UnauthorizedException("Invalid credentials");
    }

    if (user.password !== password) {
      throw new UnauthorizedException("Invalid credentials");
    }

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

Обращаемся через сервис

import { Injectable, UnauthorizedException } from "@nestjs/common";
import { UsersService } from "../users/users.service";

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UsersService) {}

  async signUp(email: string, password: string) {
    const existing = await this.usersService.findByEmail(email);

    if (existing) {
      throw new Error("User already exists");
    }

    const user = await this.usersService.create({
      email,
      password,
    });

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

  async signIn(email: string, password: string) {
    const user = await this.usersService.findByEmail(email);

    if (!user) {
      throw new UnauthorizedException("Invalid credentials");
    }

    if (user.password !== password) {
      throw new UnauthorizedException("Invalid credentials");
    }

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

На текущем масштабе оба подхода выглядят равноправными, и в этом и состоит главная ловушка: правильный выбор сейчас определяется не тем, как код смотрится в момент написания, а тем, что произойдёт с ним через год. Поэтому давайте сразу промотаем время вперёд — представим, что прошло около года активной разработки. Продукт нашёл аудиторию, пользователей стало много, а вместе с ними пришли и те, кто пытается абьюзить регистрацию. Маркетинг требует фиксировать источники трафика и проводить A/B-тесты на пользователях. Появилась многоуровневая реферальная система с бонусами и лимитами. Модуль users оброс собственными эндпоинтами и новыми полями — словом, всё то, что в любом продукте происходит ровно тогда, когда он начинает приносить деньги.

Зафиксируем конкретно, к какому списку требований нужно теперь адаптировать AuthService:

  1. Реферальная система с проверками и ограничениями: лимиты на приглашения, защита от self-referral, защита от повторных приглашений того же email

  2. Усложнившаяся регистрация с анти-абьюз-проверками — по IP, по deviceId, по факту повторной регистрации с того же устройства

  3. Расширившийся модуль users — новые поля (источник трафика, deviceId, флаги верификации), отдельные эндпоинты, собственные сценарии

  4. Требования маркетинга — аналитика регистраций, A/B-тесты, фиксация источников трафика, экспорт событий

Оговорка о транзакциях. В продакшене регистрация требует идемпотентности, защиты от гонок и аккуратной транзакционной разбивки. В примерах статьи всё это сознательно опущено — речь о декомпозиции, а не транзакционности. И сразу оговорим, чтобы не ловить вопрос: «считать всё это одной большой транзакцией» — тоже неверный default. Длинный транзакционный блок с походом во внешнюю аналитику, бонусами и пересчётом счётчиков будет держать строки заблокированными секундами и под нагрузкой роняет базу. Корректная транзакционная разбивка — это отдельная задача, выходящая за рамки статьи.

AuthService.signUp V2

async signUp(
  email: string,
  password: string,
  referralCode?: string,
  adSourceCode?: string,
  ip?: string,
  deviceId?: string,
): Promise<SignUpResponse> {
  const existingUserByEmail = await this.usersRepository.findOne({
    where: { email },
  });
  if (existingUserByEmail) {
    throw new BadRequestException("User already exists");
  }

  const registrationsFromIp = await this.usersRepository.count({
    where: { registrationIp: ip },
  });
  if (registrationsFromIp > 5) {
    throw new BadRequestException("Too many registrations from this IP");
  }

  const existingUserByDevice = await this.usersRepository.findOne({
    where: { deviceId },
  });
  if (existingUserByDevice) {
    throw new BadRequestException("Device already used");
  }

  const adSource = adSourceCode
    ? await this.adSourceRepository.findOne({ where: { code: adSourceCode } })
    : null;
  if (adSourceCode && !adSource) {
    throw new BadRequestException("Invalid ad source");
  }

  if (adSource) {
    const experimentGroup = Math.random() > 0.5 ? "A" : "B";

    await this.adSourceRepository.increment(
      { id: adSource.id },
      "registrationsCount",
      1,
    );

    await this.analyticsRepository.save({
      type: "experiment_assignment",
      group: experimentGroup,
      source: adSource.code,
    });
  }

  const referral = referralCode
    ? await this.referralsRepository.findOne({
        where: { code: referralCode },
        relations: ["owner"],
      })
    : null;
  if (referralCode && !referral) {
    throw new BadRequestException("Invalid referral code");
  }

  const referredByUser = referral?.owner ?? null;

  if (referredByUser) {
    const referralsByOwnerCount = await this.referralsRepository.count({
      where: { owner: { id: referredByUser.id } },
    });
    if (referralsByOwnerCount > 10) {
      throw new BadRequestException("Referral limit exceeded");
    }

    const existingReferralForEmail = await this.referralsRepository.findOne({
      where: {
        owner: { id: referredByUser.id },
        invitedUser: { email },
      },
      relations: ["invitedUser"],
    });
    if (existingReferralForEmail) {
      throw new BadRequestException("Referral abuse detected");
    }

    if (referredByUser.email === email) {
      throw new BadRequestException("Self-referral not allowed");
    }
  }

  const newUser = this.usersRepository.create({
    email,
    password,
    adSource,
    registrationIp: ip,
    deviceId,
    isVerified: false,
  });
  await this.usersRepository.save(newUser);

  if (referredByUser) {
    await this.bonusRepository.save({
      userId: referredByUser.id,
      amount: 100,
      type: "referral_reward",
    });

    const parentReferral = await this.referralsRepository.findOne({
      where: { invitedUser: { id: referredByUser.id } },
      relations: ["owner"],
    });
    if (parentReferral) {
      await this.bonusRepository.save({
        userId: parentReferral.owner.id,
        amount: 50,
        type: "second_level_referral",
      });
    }

    await this.referralsRepository.save({
      owner: referredByUser,
      invitedUser: newUser,
    });
  }

  await this.analyticsRepository.save({
    type: "user_registered",
    userId: newUser.id,
    source: adSource?.code,
    ip,
  });

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

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

V2 пошла в прод и сделала то, что V1 не могла. Рефералка начала приводить трафик дешевле платной рекламы, инфлюенсеры это заметили и сами начали стучаться с предложениями. В дашбордах продакта и финансов цифры впервые за долгое время оказались зелёными в одном и том же квартале. Проект жив, проект растёт, проект зарабатывает.

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

На стол лёг следующий пакет «давно собирались». Партнёрская программа с блогерами и стримерами. Разные модели монетизации — revenue share, бонусы, уровни. Анти-фрод посерьёзнее, с несколькими сценариями и скорингом. Расширенная аналитика по маркетингу, продукту и финансам. И ещё дополнительные проверки и ограничения для рефералок. Каждый пункт сам по себе нормальный — ровно та же логика, только чуть больше сценариев.

Наш герой открывает auth.service.ts и продумывает самый быстрый путь — всё в один сервис, без лишних рефакторингов, без споров на ревью, к пятнице деплой. И вот ровно эта комбинация — успех продукта, давление продактов, личная мотивация одного инженера и пятничный дедлайн — раз за разом производит на свет один и тот же класс кода. Если вы работали в продукте, который перешёл из MVP в рост, вы эту сцену видели хотя бы раз. Сейчас увидите её ещё раз — и в подробностях.

Прежде чем смотреть, что в итоге окажется в auth.service.ts, зафиксируем, что формально лежит в ТЗ к этому этапу:

  1. Партнёрская программа с блогерами и стримерами — отдельные категории партнёров, верификация, отдельные статусы и переходы между ними

  2. Расширенная модель монетизации — revenue share, многоуровневые бонусы, уровни партнёрства, разные правила начисления для разных категорий

  3. Усложнённый анти-фрод — несколько сценариев (новый пользователь, рефералка, партнёрский клик), скоринговая модель, ручные блокировки

  4. Расширенная аналитика — отдельные слои данных для маркетинга, продукта и финансов; экспорт событий во внешние системы

  5. Дополнительные проверки и ограничения для рефералок — лимиты по времени, по сегментам пользователей, по источникам трафика

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

AuthService.signUp V3

async signUp(
  email: string,
  password: string,
  referralCode?: string,
  adSourceCode?: string,
  ip?: string,
  deviceId?: string,
): Promise<SignUpResponse> {
  const existingUserByEmail = await this.usersRepository.findOne({
    where: { email },
  });
  if (existingUserByEmail) {
    throw new BadRequestException("User already exists");
  }

  const registrationsFromIp = await this.usersRepository.count({
    where: { registrationIp: ip },
  });
  if (registrationsFromIp > 5) {
    throw new BadRequestException("Too many registrations from this IP");
  }

  const existingUserByDevice = await this.usersRepository.findOne({
    where: { deviceId },
  });
  if (existingUserByDevice) {
    throw new BadRequestException("Device already used");
  }

  const fraudScore =
    (registrationsFromIp ?? 0) * 10 +
    (existingUserByDevice ? 50 : 0) +
    (ip?.startsWith("192.") ? 20 : 0);
  if (fraudScore > 70) {
    throw new BadRequestException("Fraud detected");
  }

  const adSource = adSourceCode
    ? await this.adSourceRepository.findOne({ where: { code: adSourceCode } })
    : null;
  if (adSourceCode && !adSource) {
    throw new BadRequestException("Invalid ad source");
  }

  if (adSource) {
    const experimentGroup = Math.random() > 0.5 ? "A" : "B";

    await this.adSourceRepository.increment(
      { id: adSource.id },
      "registrationsCount",
      1,
    );

    await this.analyticsRepository.save({
      type: "experiment_assignment",
      group: experimentGroup,
      source: adSource.code,
    });
  }

  const referral = referralCode
    ? await this.referralsRepository.findOne({
        where: { code: referralCode },
        relations: ["owner", "influencerPartner"],
      })
    : null;
  if (referralCode && !referral) {
    throw new BadRequestException("Invalid referral code");
  }

  const influencerPartner = referral?.influencerPartner ?? null;
  const referredByUser =
    referral && !influencerPartner ? referral.owner : null;

  let calculatedReward = 0;
  if (influencerPartner) {
    await this.partnerRepository.increment(
      { id: influencerPartner.id },
      "registrationsCount",
      1,
    );

    if (influencerPartner.type === "blogger") {
      const audienceSize = influencerPartner.audienceSize ?? 1000;
      const ctr = influencerPartner.ctr ?? 0.02;
      const engagementScore = audienceSize * ctr;

      calculatedReward =
        20 + engagementScore * 0.01 + (engagementScore > 1000 ? 50 : 0);

      if (engagementScore > 5000) {
        calculatedReward *= 1.5;
      }
    } else if (influencerPartner.type === "streamer") {
      const avgViewers = influencerPartner.avgViewers ?? 100;
      const streamHours = influencerPartner.streamHours ?? 2;
      const retentionFactor = Math.min(streamHours / 4, 1);

      calculatedReward =
        avgViewers * 0.5 * retentionFactor + (avgViewers > 1000 ? 100 : 0);

      if (streamHours > 6) {
        calculatedReward *= 1.2;
      }
    } else if (influencerPartner.type === "partner") {
      const revenueShare = influencerPartner.revenueShare ?? 0.1;
      const baseValue = influencerPartner.baseValue ?? 200;
      const tierMultiplier =
        influencerPartner.tier === "gold"
          ? 2
          : influencerPartner.tier === "silver"
            ? 1.5
            : 1;

      calculatedReward = baseValue * revenueShare * tierMultiplier;

      if (influencerPartner.kpiAchieved) {
        calculatedReward += 300;
      }
    }

    await this.analyticsRepository.save({
      type: "marketing_conversion",
      source: influencerPartner.type,
      reward: calculatedReward,
    });

    await this.analyticsRepository.save({
      type: "revenue_projection",
      expectedRevenue: calculatedReward * 10,
    });

    await this.analyticsRepository.save({
      type: "user_segment",
      segment:
        influencerPartner.type === "streamer" ? "gamers" : "general",
    });
  }

  if (referredByUser) {
    const referralsByOwnerCount = await this.referralsRepository.count({
      where: { owner: { id: referredByUser.id } },
    });
    if (referralsByOwnerCount > 10) {
      throw new BadRequestException("Referral limit exceeded");
    }

    const existingReferralForEmail = await this.referralsRepository.findOne({
      where: {
        owner: { id: referredByUser.id },
        invitedUser: { email },
      },
      relations: ["invitedUser"],
    });
    if (existingReferralForEmail) {
      throw new BadRequestException("Referral abuse detected");
    }

    if (referredByUser.email === email) {
      throw new BadRequestException("Self-referral not allowed");
    }
  }

  const newUser = this.usersRepository.create({
    email,
    password,
    adSource,
    registrationIp: ip,
    deviceId,
    isVerified: false,
  });
  await this.usersRepository.save(newUser);

  if (referredByUser) {
    await this.bonusRepository.save({
      userId: referredByUser.id,
      amount: 100,
      type: "referral_reward",
    });

    const parentReferral = await this.referralsRepository.findOne({
      where: { invitedUser: { id: referredByUser.id } },
      relations: ["owner"],
    });
    if (parentReferral) {
      await this.bonusRepository.save({
        userId: parentReferral.owner.id,
        amount: 50,
        type: "second_level_referral",
      });
    }

    await this.referralsRepository.save({
      owner: referredByUser,
      invitedUser: newUser,
    });
  }

  if (influencerPartner) {
    const partnerOwner = await this.usersRepository.findOne({
      where: { id: influencerPartner.ownerUserId },
    });
    if (partnerOwner) {
      await this.bonusRepository.save({
        userId: partnerOwner.id,
        amount: calculatedReward,
        type: "influencer_reward",
      });

      await this.analyticsRepository.save({
        type: "influencer_reward_paid",
        partnerId: influencerPartner.id,
        amount: calculatedReward,
      });
    }
  }

  await this.analyticsRepository.save({
    type: "user_registered",
    userId: newUser.id,
    source: adSource?.code,
    ip,
  });

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

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

На этом месте у читателя возникает естественная мысль: «Хорошо, причина понятна — AuthService держит логику нескольких независимых доменов в одном методе. Значит, нужно завести UsersService, ReferralsService, MarketingService, FraudService, PartnerService и разнести по ним всю логику signUp по принципу один сервис — один домен; AuthService останется только оркестратором». Этот ответ — стандартная рекомендация NestJS-сообщества и буквально первый совет на любом ревью такого кода. Он звучит правильно, выглядит правильно и в моменте действительно даёт видимое улучшение.

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