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

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

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

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

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

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

Краткий пересказ, чтобы не возвращаться к части 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 отвечает за проверки доступа

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

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