DEV Community

Victor Shkirov
Victor Shkirov

Posted on

Feature Based Clean Architecture. Part 2: Decomposition into Services: An Analysis of the Approach's Limits

An architectural doctrine for NestJS projects: a breakdown of typical codebase degradation scenarios and the structural constraints that keep them from emerging as the feature set grows.

A quick recap, to avoid going back to part 1. We left AuthService.signUp in a state that needs no defense: two hundred lines in one function, six parameters at the input, four independent business domains in one method, and five different repositories in one dependency. And we've already formulated which answer comes up first: split it across services — UsersService, ReferralsService, MarketingService, FraudService, PartnerService — each with its own zone of responsibility; leave AuthService as the orchestrator. This answer is the standard one, recognized by the NestJS community, and any team will adopt it for refactoring without extra discussion.

Part 2 is about what happens when the team honestly carries this refactoring out. Spoiler: the code will become more pleasant on the eye, more files will appear, the signUp method will slim down — and at the same time, everything that was bad in V3 will stay bad, just in new packaging. To see this, you first have to go through the refactoring step by step, the way any normal team would.

The team's standard reaction to code in this state is to open a separate refactoring ticket. The plan is obvious: while preserving the current behavior, split the logic from one method across several services, each with its own zone of responsibility. AuthService remains the orchestration point, and the other services perform concrete operations in their domains.

AuthService (orchestration)
│
├── UsersService        — user creation, lookup by email, working with user data
├── AntiFraudService    — abuse checks (IP, device, behavioral scoring)
├── ReferralService     — referral validation, link creation, limits and abuse protection
├── PartnerService      — handling partner programs (bloggers, streamers, partners) and revenue calculation
├── BonusService        — bonus accrual (referral, partner, multi-level)
├── AnalyticsService    — event recording (registration, experiments, conversions, segmentation)
├── AdSourceService     — working with traffic sources (lookup, increments, A/B tests)
Enter fullscreen mode Exit fullscreen mode

The team sits down to the refactoring with this plan in hand. The ticket goes into work, gets wrapped in tests, passes the architect's review, and a few days later auth.service.ts ends up roughly like this.

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

A note on error handling. Further on in the code, you'll see that the services signUp depends on (all the ones we just extracted) start returning not thrown exceptions, but an explicit Result<T, E>. This is an object that communicates the operation's outcome through the .isErr() method and access to .value or .error. The change is deliberate: each internal service handles errors as part of the function's contract, and the caller sees the entire set of possible outcomes right in the type. signUp itself remains the boundary point between business logic and HTTP transport — it accepts the Result from each call and converts errors on the spot into the appropriate HttpException, because the NestJS filter at the HTTP level expects exactly those. The convenient thing about this separation is that Result-style and throw-style no longer compete: inside services — Result; at the AuthService boundary — a concrete BadRequestException / ConflictException / ForbiddenException / InternalServerErrorException, which NestJS will turn into the right HTTP code. The specific implementation of Result is a matter of preference. I use a monad because over the long haul I find it more convenient: the compiler forces every outcome to be made explicit. Everything shown below is equally implementable through discriminated unions, any library with similar semantics, or classical try/catch — the architectural meaning doesn't change. If you want to see the industry standard for this approach in TypeScript, it's the neverthrow library — its API is what I'm using in the code. To note in advance: switching to Result on its own doesn't cure anything in the architecture — it only makes errors visible. Everything structural we've discussed stays in place. It just no longer hides behind throws deep in the call stack.

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(checkIpResult.error);
  }

  const checkDeviceResult = await this.antiFraudService.checkDevice(deviceId);
  if (checkDeviceResult.isErr()) {
    throw new ForbiddenException(checkDeviceResult.error);
  }

  const checkBehaviorResult = await this.antiFraudService.checkBehavior(
    ip,
    deviceId,
  );
  if (checkBehaviorResult.isErr()) {
    throw new ForbiddenException(checkBehaviorResult.error);
  }

  const resolveAdSourceResult = adSourceCode
    ? await this.adSourceService.resolve(adSourceCode)
    : ok(undefined);

  if (resolveAdSourceResult.isErr()) {
    throw new BadRequestException(resolveAdSourceResult.error);
  }
  const adSource = resolveAdSourceResult.value;

  if (adSource) {
    const incrementAdSourceResult = await this.adSourceService.increment(
      adSource.id,
    );

    if (incrementAdSourceResult.isErr()) {
      throw new InternalServerErrorException(incrementAdSourceResult.error);
    }

    const trackExperimentResult = await this.analyticsService.trackExperiment(
      { source: adSource.code },
    );

    if (trackExperimentResult.isErr()) {
      throw new InternalServerErrorException(trackExperimentResult.error);
    }
  }

  const getReferralResult = referralCode
    ? await this.referralService.getByCode(referralCode)
    : ok(undefined);

  if (getReferralResult.isErr()) {
    throw new BadRequestException(getReferralResult.error);
  }
  const referral = getReferralResult.value;

  const processPartnerResult =
    referral && referral.influencerPartner
      ? await this.partnerService.processPartner(referral)
      : ok(undefined);

  if (processPartnerResult.isErr()) {
    throw new InternalServerErrorException(processPartnerResult.error);
  }

  const partnerResult = processPartnerResult.value;

  const validateReferralResult =
    referral && !referral.influencerPartner
      ? await this.referralService.validateReferral(referral, email)
      : ok(undefined);

  if (validateReferralResult.isErr()) {
    throw new BadRequestException(validateReferralResult.error);
  }

  const referralOwner = validateReferralResult.value;

  const findUserResult = await this.usersService.findByEmail(email);

  if (findUserResult.isErr()) {
    throw new InternalServerErrorException(findUserResult.error);
  }

  if (findUserResult.value) {
    throw new ConflictException("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(createUserResult.error);
    }

    throw new InternalServerErrorException(createUserResult.error);
  }
  const newUser = createUserResult.value;

  if (referralOwner) {
    const giveReferralBonusResult =
      await this.bonusService.giveReferralBonus(referralOwner.id);

    if (giveReferralBonusResult.isErr()) {
      throw new InternalServerErrorException(giveReferralBonusResult.error);
    }

    const createReferralResult = await this.referralService.createReferral(
      referralOwner,
      newUser,
    );
    if (createReferralResult.isErr()) {
      throw new InternalServerErrorException(createReferralResult.error);
    }
  }

  if (partnerResult) {
    const givePartnerRewardResult =
      await this.bonusService.givePartnerReward(
        partnerResult.ownerId,
        partnerResult.reward,
      );

    if (givePartnerRewardResult.isErr()) {
      throw new InternalServerErrorException(givePartnerRewardResult.error);
    }

    const trackPartnerRewardResult =
      await this.analyticsService.trackPartnerReward(partnerResult);

    if (trackPartnerRewardResult.isErr()) {
      throw new InternalServerErrorException(trackPartnerRewardResult.error);
    }
  }

  const trackRegistrationResult =
    await this.analyticsService.trackRegistration({
      userId: newUser.id,
      source: adSource?.code,
      ip,
    });

  if (trackRegistrationResult.isErr()) {
    throw new InternalServerErrorException(trackRegistrationResult.error);
  }

  return {
    id: newUser.id,
    email: newUser.email,
  };
}
Enter fullscreen mode Exit fullscreen mode

This version is what the team shows at demo. At the AuthService.signUp level, everything really is the way it was meant to be: each dependency occupies its own zone of responsibility, the orchestration has stayed thin, and you can point a finger at the code and immediately see where anti-fraud lives, where partners are, where analytics is. The architect nods, review closes in fifteen minutes. But the architectural trap doesn't lie in AuthService.signUp — and never did. To see it, you have to stop looking at the orchestrator and open one of those services we've just carefully extracted. Let's take the first one in order — UsersService.

The Users module's evolution

In parallel with sign-up growing more complex, the users module itself wasn't standing still either. The frontend needed methods for displaying and editing the profile; analytics needed counters and slices; marketing needed user attributes and segmentation; support needed administrative operations. What at the start of the article was a single small module with one table has, by this point, turned into a self-contained domain with its own set of use-cases. By the current stage, UsersService is responsible for, at minimum, the following:

  • fetching the user profile
  • updating the profile (bio, avatar, username)
  • updating account settings
  • account privacy (public / private)
  • fetching basic stats (number of followers and follows)
  • managing user preferences (language, theme, notifications)
  • fetching the current user (/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
Enter fullscreen mode Exit fullscreen mode

For this set of scenarios, the module has gained its own controller, in which each use-case has its own endpoint. At this stage, the controller looks like this:

@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);
  }
}
Enter fullscreen mode Exit fullscreen mode

At the controller level, the structure looks exemplary: seven endpoints — seven zones of responsibility, each with its own DTO, none of them tangling with the others. A natural question arises — and what's happening at this moment to the service the controller relies on? The logical expectation is this: since the controller is cleanly split across use-cases, UsersService should mirror that structure — a separate method per scenario, a separate zone of responsibility, the same discipline inside. That's how it's set up in most textbook examples, and that's how it's recommended in the NestJS documentation. Let's open users.service.ts and see what ended up in a real project instead of the expectation.

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

Visually, UsersService looks acceptable: types are in place, errors are handled, method names read clearly. But it's precisely at this point that the main structural signal — the reason we opened this file first — reveals itself. UsersService has a special status that distinguishes it from any other service in the system: it is not a feature service, but a data service — it doesn't answer for a business scenario, it answers for the user entity itself, which the rest of the application reaches into one way or another. And that's exactly why a queue gradually forms around it.

AuthService is already here — it was first in line, during sign-up, as we saw in signUp V4/V5. The controller is here too — it returns the user their own data. Over the next few sprints, almost every other module in the product will join that queue. Feed will want to know who the user follows and who they allow to read them. Notifications — where to send the push, whether notifications are enabled, and whether the recipient is blocked. Comments, Likes, and Follows — that the user exists, that they're not private (or that the viewer is subscribed to them), plus username and avatar for display. Search — filter results by privacy and return the profile. Media — check upload permissions. Moderation and anti-fraud — status, behavior, the history of actions. And all of these requests — every single one, without exception — will land in the same file.

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

At this point the team kicks off the same ritual that any project with a bloated service goes through at least once — and which the reader has just seen in this very article applied to another service. The decomposition that initially looked obvious and logical produced the opposite effect over time: UsersService turned into a file no one wants to open alone, and every new developer in their first week phrases the same thing: "let me rewrite this." The standard response to this state is well known to everyone — add more services. Since one class has grown disproportionately, let's split it into several smaller ones, each handed its own piece. Especially since the boundaries inside look obvious — the same use-cases the controller serves:

  • the user profile
  • account settings
  • privacy
  • preferences
  • stats
  • checks for other modules

The decomposition plan ends up identical in form to the one we did for AuthService a few pages back:

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
Enter fullscreen mode Exit fullscreen mode

Now things seem to have gotten better:

  • UserProfileService is responsible for the profile
  • UserSettingsService is responsible for the settings
  • UserPrivacyService is responsible for privacy
  • UserPreferencesService is responsible for preferences
  • UserStatsService is responsible for stats
  • UserAccessService is responsible for access checks

It seems we've finally put things in order. Each service is responsible for its own piece, files are shorter, methods are flat, dependencies on the diagram draw as arrows in one direction. At demo, this looks like a win.

And at exactly this moment, the next ticket lands in the team — the one in which all this will start to break. No architectural upheavals — just another ordinary feature, a day's work. Let's go through it.

Top comments (0)