Архитектурная доктрина для NestJS-проектов: разбор типовых сценариев деградации кодовой базы и структурные ограничения, обеспечивающие её отсутствие при росте функционала.
Эта статья — разбор того, как типичный бэкенд на NestJS деградирует с ростом функционала и как идеи Clean Architecture позволяют этого избежать. Я пройду по полному циклу: покажу на примере «до и после», как feature-based-структура, которую сегодня продвигают как стандарт, теряет управляемость с масштабом; разберу типичные сценарии деградации; оценю в деньгах и человеко-часах, во что они обходятся бизнесу; объясню, почему именно такая кодовая база заставляет команды дробить монолит на микросервисы задолго до того, как это оправдано. После этого я предложу подход, направленный против деградации, — и приведу для него формальное математическое обоснование. К концу статьи у вас будет и аргументация, и инструменты, чтобы применить этот подход к своим системам.
В качестве сквозного примера возьмём задачу, которую разбирают на System Design-собеседованиях из раза в раз: бэкенд для сервиса класса Twitter. Минимальный набор инструментов очевиден — база данных и приложение. Вопросы предельной производительности, шардирования и горизонтального масштабирования мы сознательно вынесем за скобки: статья про структуру кода, а не про пропускную способность. Стек зафиксируем сразу — Node.js и фреймворк NestJS. Начнём с того, что выпишем функциональные требования к системе.
-
Регистрация и авторизация
-
Создание твита
-
Лента (feed)
-
Подписки (follow / unfollow)
-
Профиль пользователя
-
Лайки
-
Ретвиты
-
Комментарии (replies)
-
Поиск (пользователей, твитов, хэштегов)
-
Уведомления (лайки, подписки, ответы)
-
Медиа (изображения / видео)
Очевидно, что реальный Twitter устроен на порядок сложнее и строился годами командой в сотни инженеров — но цель статьи не в том, чтобы воспроизвести продукт, а в том, чтобы рассмотреть архитектурную эволюцию на знакомой предметной области. Список фич зафиксирован, схема базы и набор эндпоинтов на этом этапе вырисовываются практически без раздумий, стек выбран. Открываем документацию NestJS — и с первой же страницы документация предлагает нам опорную структуру проекта.
src/
├── main.ts
├── app.module.ts
│
├── modules/
│ ├── auth/
│ ├── users/
│ ├── tweets/
│ ├── feed/
│ ├── likes/
│ ├── comments/
│ ├── retweets/
│ ├── follows/
│ ├── notifications/
│ ├── search/
│ └── media/
│
├── common/
│ ├── guards/
│ ├── interceptors/
│ ├── filters/
│ ├── decorators/
│ └── utils/
│
├── database/
│ ├── prisma/ или typeorm/
│ └── migrations/
│
├── config/
│ └── configuration.ts
Помимо структуры верхнего уровня, документация описывает и рекомендованный состав одного модуля — какие файлы и в каком порядке имеет смысл создавать. Эта рекомендация одинакова для модулей с разной природой: и auth, и tweets, и search собираются по одному и тому же шаблону. Покажу на примере модуля tweets:
src/modules/tweets/
├── tweets.module.ts
├── tweets.controller.ts
├── tweets.service.ts
├── dto/
│ └── create-tweet.dto.ts
├── entities/
│ └── tweet.entity.ts
На первый взгляд схема выглядит аккуратно: ясное разделение, очевидные правила размещения, низкий порог входа для нового разработчика. На практике же — без дополнительной архитектурной дисциплины — она через полгода превращается в трудно поддерживаемый код. Дальше мы увидим, как именно: шаг за шагом, через последовательность локально разумных решений. Начнём с модуля, который есть почти в любом продукте — пользователи и авторизация.
Минимально необходимый функционал: две ручки — регистрация и вход.
@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("sign-up")
async signUp(@Body() dto: SignUpDto): Promise<SignUpResponse> {
return this.authService.signUp(dto.email, dto.password);
}
@Post("sign-in")
async signIn(@Body() dto: SignInDto): Promise<SignInResponse> {
return this.authService.signIn(dto.email, dto.password);
}
}
В качестве ORM по ходу статьи будем использовать TypeORM — выбор не принципиален для разговора об архитектуре, и всё, что ниже, легко переносится на Prisma, MikroORM или Drizzle. Просто нужен инструмент, на котором удобно показывать запросы. Сначала опишем сущность пользователя.
@Entity("users")
export class User {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
}
Согласно принятой структуре, код, относящийся к работе с пользователями, должен жить в src/modules/users. Значит, и логика создания записи о пользователе в базе данных формально принадлежит этому модулю. Это уже неплохая дисциплина — лучше, чем вставлять SQL-запросы прямо в AuthService. Но в этой точке у разработчика реально два варианта: обращаться к репозиторию пользователей напрямую из AuthService или ходить через UsersService. На маленьком масштабе оба варианта работают и оба проходят ревью — поэтому сначала рассмотрим их рядом.
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async signUp(email: string, password: string) {
const existing = await this.usersRepository.findOne({
where: { email },
});
if (existing) {
throw new Error("User already exists");
}
const user = this.usersRepository.create({
email,
password,
});
await this.usersRepository.save(user);
return {
id: user.id,
email: user.email,
};
}
async signIn(email: string, password: string) {
const user = await this.usersRepository.findOne({
where: { email },
});
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
if (user.password !== password) {
throw new UnauthorizedException("Invalid credentials");
}
return {
id: user.id,
email: user.email,
};
}
}
Обращаемся через сервис
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { UsersService } from "../users/users.service";
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
async signUp(email: string, password: string) {
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new Error("User already exists");
}
const user = await this.usersService.create({
email,
password,
});
return {
id: user.id,
email: user.email,
};
}
async signIn(email: string, password: string) {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
}
if (user.password !== password) {
throw new UnauthorizedException("Invalid credentials");
}
return {
id: user.id,
email: user.email,
};
}
}
На текущем масштабе оба подхода выглядят равноправными, и в этом и состоит главная ловушка: правильный выбор сейчас определяется не тем, как код смотрится в момент написания, а тем, что произойдёт с ним через год. Поэтому давайте сразу промотаем время вперёд — представим, что прошло около года активной разработки. Продукт нашёл аудиторию, пользователей стало много, а вместе с ними пришли и те, кто пытается абьюзить регистрацию. Маркетинг требует фиксировать источники трафика и проводить A/B-тесты на пользователях. Появилась многоуровневая реферальная система с бонусами и лимитами. Модуль users оброс собственными эндпоинтами и новыми полями — словом, всё то, что в любом продукте происходит ровно тогда, когда он начинает приносить деньги.
Зафиксируем конкретно, к какому списку требований нужно теперь адаптировать AuthService:
-
Реферальная система с проверками и ограничениями: лимиты на приглашения, защита от self-referral, защита от повторных приглашений того же email
-
Усложнившаяся регистрация с анти-абьюз-проверками — по IP, по
deviceId, по факту повторной регистрации с того же устройства -
Расширившийся модуль
users— новые поля (источник трафика,deviceId, флаги верификации), отдельные эндпоинты, собственные сценарии -
Требования маркетинга — аналитика регистраций, A/B-тесты, фиксация источников трафика, экспорт событий
Оговорка о транзакциях. В продакшене регистрация требует идемпотентности, защиты от гонок и аккуратной транзакционной разбивки. В примерах статьи всё это сознательно опущено — речь о декомпозиции, а не транзакционности. И сразу оговорим, чтобы не ловить вопрос: «считать всё это одной большой транзакцией» — тоже неверный default. Длинный транзакционный блок с походом во внешнюю аналитику, бонусами и пересчётом счётчиков будет держать строки заблокированными секундами и под нагрузкой роняет базу. Корректная транзакционная разбивка — это отдельная задача, выходящая за рамки статьи.
AuthService.signUp V2
async signUp(
email: string,
password: string,
referralCode?: string,
adSourceCode?: string,
ip?: string,
deviceId?: string,
): Promise<SignUpResponse> {
const existingUserByEmail = await this.usersRepository.findOne({
where: { email },
});
if (existingUserByEmail) {
throw new BadRequestException("User already exists");
}
const registrationsFromIp = await this.usersRepository.count({
where: { registrationIp: ip },
});
if (registrationsFromIp > 5) {
throw new BadRequestException("Too many registrations from this IP");
}
const existingUserByDevice = await this.usersRepository.findOne({
where: { deviceId },
});
if (existingUserByDevice) {
throw new BadRequestException("Device already used");
}
const adSource = adSourceCode
? await this.adSourceRepository.findOne({ where: { code: adSourceCode } })
: null;
if (adSourceCode && !adSource) {
throw new BadRequestException("Invalid ad source");
}
if (adSource) {
const experimentGroup = Math.random() > 0.5 ? "A" : "B";
await this.adSourceRepository.increment(
{ id: adSource.id },
"registrationsCount",
1,
);
await this.analyticsRepository.save({
type: "experiment_assignment",
group: experimentGroup,
source: adSource.code,
});
}
const referral = referralCode
? await this.referralsRepository.findOne({
where: { code: referralCode },
relations: ["owner"],
})
: null;
if (referralCode && !referral) {
throw new BadRequestException("Invalid referral code");
}
const referredByUser = referral?.owner ?? null;
if (referredByUser) {
const referralsByOwnerCount = await this.referralsRepository.count({
where: { owner: { id: referredByUser.id } },
});
if (referralsByOwnerCount > 10) {
throw new BadRequestException("Referral limit exceeded");
}
const existingReferralForEmail = await this.referralsRepository.findOne({
where: {
owner: { id: referredByUser.id },
invitedUser: { email },
},
relations: ["invitedUser"],
});
if (existingReferralForEmail) {
throw new BadRequestException("Referral abuse detected");
}
if (referredByUser.email === email) {
throw new BadRequestException("Self-referral not allowed");
}
}
const newUser = this.usersRepository.create({
email,
password,
adSource,
registrationIp: ip,
deviceId,
isVerified: false,
});
await this.usersRepository.save(newUser);
if (referredByUser) {
await this.bonusRepository.save({
userId: referredByUser.id,
amount: 100,
type: "referral_reward",
});
const parentReferral = await this.referralsRepository.findOne({
where: { invitedUser: { id: referredByUser.id } },
relations: ["owner"],
});
if (parentReferral) {
await this.bonusRepository.save({
userId: parentReferral.owner.id,
amount: 50,
type: "second_level_referral",
});
}
await this.referralsRepository.save({
owner: referredByUser,
invitedUser: newUser,
});
}
await this.analyticsRepository.save({
type: "user_registered",
userId: newUser.id,
source: adSource?.code,
ip,
});
return {
id: newUser.id,
email: newUser.email,
};
}
На этом месте сторонник такого кода скажет: «Ну и что? Всё работает, бизнес-сценарий покрыт целиком, один метод честно проводит регистрацию от начала и до конца». И формально это правда. Но если присмотреться к тому, что именно лежит внутри signUp, картина другая: одна функция теперь одновременно занимается аутентификацией, анти-фрод-логикой, маркетинговыми экспериментами, реферальной механикой и аналитикой. Она зависит от пяти разных репозиториев и от четырёх независимых доменов бизнеса. И вот это — а не количество строк — настоящая проблема. Через ещё один продуктовый квартал любая новая фича — антибот, гео-таргетинг, верификация email — будет шиться в это же место, потому что «все требования к регистрации живут в signUp».
V2 пошла в прод и сделала то, что V1 не могла. Рефералка начала приводить трафик дешевле платной рекламы, инфлюенсеры это заметили и сами начали стучаться с предложениями. В дашбордах продакта и финансов цифры впервые за долгое время оказались зелёными в одном и том же квартале. Проект жив, проект растёт, проект зарабатывает.
Продакты ловят волну и начинают подгонять: «парни, рынок открылся, давайте быстрее, конкуренты не спят». В этот момент в команде находится тот, кто эту волну ловит ещё и лично. Складывает у себя в голове: фича большая, заметная, как раз перед performance review; если выкатить первым и без багов — можно идти к менеджеру и просить тимлид-грейд, можно начать ходить на бизнес-встречи, стать тем самым инженером, к которому продакт сначала идёт спросить, а уже потом пишет пользовательскую историю. Стимул понятный, человеческий — не плохой и не хороший, просто реальный.
На стол лёг следующий пакет «давно собирались». Партнёрская программа с блогерами и стримерами. Разные модели монетизации — revenue share, бонусы, уровни. Анти-фрод посерьёзнее, с несколькими сценариями и скорингом. Расширенная аналитика по маркетингу, продукту и финансам. И ещё дополнительные проверки и ограничения для рефералок. Каждый пункт сам по себе нормальный — ровно та же логика, только чуть больше сценариев.
Наш герой открывает auth.service.ts и продумывает самый быстрый путь — всё в один сервис, без лишних рефакторингов, без споров на ревью, к пятнице деплой. И вот ровно эта комбинация — успех продукта, давление продактов, личная мотивация одного инженера и пятничный дедлайн — раз за разом производит на свет один и тот же класс кода. Если вы работали в продукте, который перешёл из MVP в рост, вы эту сцену видели хотя бы раз. Сейчас увидите её ещё раз — и в подробностях.
Прежде чем смотреть, что в итоге окажется в auth.service.ts, зафиксируем, что формально лежит в ТЗ к этому этапу:
-
Партнёрская программа с блогерами и стримерами — отдельные категории партнёров, верификация, отдельные статусы и переходы между ними
-
Расширенная модель монетизации — revenue share, многоуровневые бонусы, уровни партнёрства, разные правила начисления для разных категорий
-
Усложнённый анти-фрод — несколько сценариев (новый пользователь, рефералка, партнёрский клик), скоринговая модель, ручные блокировки
-
Расширенная аналитика — отдельные слои данных для маркетинга, продукта и финансов; экспорт событий во внешние системы
-
Дополнительные проверки и ограничения для рефералок — лимиты по времени, по сегментам пользователей, по источникам трафика
Каждый пункт — обычный продуктовый запрос: ничего экзотического, ничего архитектурно-провокационного. Их и реализуют как обычные продуктовые запросы.
AuthService.signUp V3
async signUp(
email: string,
password: string,
referralCode?: string,
adSourceCode?: string,
ip?: string,
deviceId?: string,
): Promise<SignUpResponse> {
const existingUserByEmail = await this.usersRepository.findOne({
where: { email },
});
if (existingUserByEmail) {
throw new BadRequestException("User already exists");
}
const registrationsFromIp = await this.usersRepository.count({
where: { registrationIp: ip },
});
if (registrationsFromIp > 5) {
throw new BadRequestException("Too many registrations from this IP");
}
const existingUserByDevice = await this.usersRepository.findOne({
where: { deviceId },
});
if (existingUserByDevice) {
throw new BadRequestException("Device already used");
}
const fraudScore =
(registrationsFromIp ?? 0) * 10 +
(existingUserByDevice ? 50 : 0) +
(ip?.startsWith("192.") ? 20 : 0);
if (fraudScore > 70) {
throw new BadRequestException("Fraud detected");
}
const adSource = adSourceCode
? await this.adSourceRepository.findOne({ where: { code: adSourceCode } })
: null;
if (adSourceCode && !adSource) {
throw new BadRequestException("Invalid ad source");
}
if (adSource) {
const experimentGroup = Math.random() > 0.5 ? "A" : "B";
await this.adSourceRepository.increment(
{ id: adSource.id },
"registrationsCount",
1,
);
await this.analyticsRepository.save({
type: "experiment_assignment",
group: experimentGroup,
source: adSource.code,
});
}
const referral = referralCode
? await this.referralsRepository.findOne({
where: { code: referralCode },
relations: ["owner", "influencerPartner"],
})
: null;
if (referralCode && !referral) {
throw new BadRequestException("Invalid referral code");
}
const influencerPartner = referral?.influencerPartner ?? null;
const referredByUser =
referral && !influencerPartner ? referral.owner : null;
let calculatedReward = 0;
if (influencerPartner) {
await this.partnerRepository.increment(
{ id: influencerPartner.id },
"registrationsCount",
1,
);
if (influencerPartner.type === "blogger") {
const audienceSize = influencerPartner.audienceSize ?? 1000;
const ctr = influencerPartner.ctr ?? 0.02;
const engagementScore = audienceSize * ctr;
calculatedReward =
20 + engagementScore * 0.01 + (engagementScore > 1000 ? 50 : 0);
if (engagementScore > 5000) {
calculatedReward *= 1.5;
}
} else if (influencerPartner.type === "streamer") {
const avgViewers = influencerPartner.avgViewers ?? 100;
const streamHours = influencerPartner.streamHours ?? 2;
const retentionFactor = Math.min(streamHours / 4, 1);
calculatedReward =
avgViewers * 0.5 * retentionFactor + (avgViewers > 1000 ? 100 : 0);
if (streamHours > 6) {
calculatedReward *= 1.2;
}
} else if (influencerPartner.type === "partner") {
const revenueShare = influencerPartner.revenueShare ?? 0.1;
const baseValue = influencerPartner.baseValue ?? 200;
const tierMultiplier =
influencerPartner.tier === "gold"
? 2
: influencerPartner.tier === "silver"
? 1.5
: 1;
calculatedReward = baseValue * revenueShare * tierMultiplier;
if (influencerPartner.kpiAchieved) {
calculatedReward += 300;
}
}
await this.analyticsRepository.save({
type: "marketing_conversion",
source: influencerPartner.type,
reward: calculatedReward,
});
await this.analyticsRepository.save({
type: "revenue_projection",
expectedRevenue: calculatedReward * 10,
});
await this.analyticsRepository.save({
type: "user_segment",
segment:
influencerPartner.type === "streamer" ? "gamers" : "general",
});
}
if (referredByUser) {
const referralsByOwnerCount = await this.referralsRepository.count({
where: { owner: { id: referredByUser.id } },
});
if (referralsByOwnerCount > 10) {
throw new BadRequestException("Referral limit exceeded");
}
const existingReferralForEmail = await this.referralsRepository.findOne({
where: {
owner: { id: referredByUser.id },
invitedUser: { email },
},
relations: ["invitedUser"],
});
if (existingReferralForEmail) {
throw new BadRequestException("Referral abuse detected");
}
if (referredByUser.email === email) {
throw new BadRequestException("Self-referral not allowed");
}
}
const newUser = this.usersRepository.create({
email,
password,
adSource,
registrationIp: ip,
deviceId,
isVerified: false,
});
await this.usersRepository.save(newUser);
if (referredByUser) {
await this.bonusRepository.save({
userId: referredByUser.id,
amount: 100,
type: "referral_reward",
});
const parentReferral = await this.referralsRepository.findOne({
where: { invitedUser: { id: referredByUser.id } },
relations: ["owner"],
});
if (parentReferral) {
await this.bonusRepository.save({
userId: parentReferral.owner.id,
amount: 50,
type: "second_level_referral",
});
}
await this.referralsRepository.save({
owner: referredByUser,
invitedUser: newUser,
});
}
if (influencerPartner) {
const partnerOwner = await this.usersRepository.findOne({
where: { id: influencerPartner.ownerUserId },
});
if (partnerOwner) {
await this.bonusRepository.save({
userId: partnerOwner.id,
amount: calculatedReward,
type: "influencer_reward",
});
await this.analyticsRepository.save({
type: "influencer_reward_paid",
partnerId: influencerPartner.id,
amount: calculatedReward,
});
}
}
await this.analyticsRepository.save({
type: "user_registered",
userId: newUser.id,
source: adSource?.code,
ip,
});
return {
id: newUser.id,
email: newUser.email,
};
}
Это случай, в котором не требуется отдельной аргументации, чтобы признать качество кода неудовлетворительным — структурные проблемы видны невооружённым глазом. Двести строк в одной функции, шесть параметров на входе, три ветки реферальной логики, три модели вознаграждения партнёров; за каждой условной строкой — отдельный бизнес-сценарий, и совокупно держать их в голове не способен ни один разработчик, кроме автора. Стоит подчеркнуть, что в этом коде намеренно опущены транзакционность, идемпотентность, валидация инвариантов, единая обработка ошибок и согласованные коды ответа: их добавление сделало бы пример нечитаемым, а ситуацию — только более характерной.
На этом месте у читателя возникает естественная мысль: «Хорошо, причина понятна — AuthService держит логику нескольких независимых доменов в одном методе. Значит, нужно завести UsersService, ReferralsService, MarketingService, FraudService, PartnerService и разнести по ним всю логику signUp по принципу один сервис — один домен; AuthService останется только оркестратором». Этот ответ — стандартная рекомендация NestJS-сообщества и буквально первый совет на любом ревью такого кода. Он звучит правильно, выглядит правильно и в моменте действительно даёт видимое улучшение.
Только проблему он не решает. И в следующей части мы пройдём по такому рефакторингу шаг за шагом и увидим, почему правильно разнести по сервисам — это не просто переложить вызовы из одного места в другое, и любой проект, в котором за «декомпозицией на сервисы» нет архитектурного правила, через тот же год снова окажется ровно в той же точке, только с другим набором имён файлов.
Автор: shkvik
