Архитектурная доктрина для 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)
получение базовой статистики (количество подписчиков и подписок)
управление пользовательскими настройками (язык, тема, нотификации)
получение текущего пользователя (
/meendpoint)
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отвечает за проверки доступа
Кажется, мы наконец-то навели порядок. Каждый сервис отвечает за свой кусок, файлы короче, методы плоские, зависимости на схеме рисуются стрелочками в одну сторону. На демо это выглядит как победа.
И именно в этот момент в команду прилетает следующий тикет, в котором всё это начнёт ломаться. Никаких архитектурных переворотов — просто ещё одна обычная фича, день работы. Разберём.
























