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

от автора

Архитектурная доктрина для 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 отвечает за проверки доступа

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1038416/