Feature Based Clean Architecture. Часть 2: Декомпозиция на сервисы: анализ ограниченности подхода
- воскресенье, 24 мая 2026 г. в 00:00:25
Архитектурная доктрина для NestJS-проектов: разбор типовых сценариев деградации кодовой базы и структурные ограничения, обеспечивающие её отсутствие при росте функционала. Навигация по серии:
Часть 1. Эволюция NestJS-приложения в неподдерживаемое состояние
Часть 2. Декомпозиция на сервисы: анализ ограниченности подхода
Часть 3. Архитектурный риск циклов в NestJS: ROI решений на горизонте пяти лет
Часть 4. FBCA: формализация границ ответственности в NestJS-модуле
Часть 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 оказывается примерно в таком виде.
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-ами в глубине вызовов.
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 тоже не стоял на месте. Фронту требовались методы для отображения и редактирования профиля, аналитике — счётчики и срезы, маркетингу — атрибуты пользователей и сегментация, поддержке — административные операции. То, что в начале статьи было одним маленьким модулем с одной таблицей, к этому моменту превратилось в самостоятельный домен с собственным набором 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 и посмотрим, что в реальном проекте оказалось вместо ожидания.
@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 и анти-фрод — статус, поведение, историю действий. И все эти запросы — все, без исключения — приземлятся в один и тот же файл.
@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 отвечает за проверки доступа
Кажется, мы наконец-то навели порядок. Каждый сервис отвечает за свой кусок, файлы короче, методы плоские, зависимости на схеме рисуются стрелочками в одну сторону. На демо это выглядит как победа.
И именно в этот момент в команду прилетает следующий тикет, в котором всё это начнёт ломаться. Никаких архитектурных переворотов — просто ещё одна обычная фича, день работы. Разберём.