Архитектурная доктрина для NestJS-проектов: разбор типовых сценариев деградации кодовой базы и структурные ограничения, обеспечивающие её отсутствие при росте функционала.
Три части мы смотрели, как «обычный» NestJS-проект приходит к forwardRef и прочей стенке. Пора отвечать на вопрос «как этого не делать». Тут самое время произнести «Clean Architecture» — и сразу оговориться. Любой, кто читал про неё больше пяти минут, знает: вокруг этого словосочетания пасётся столько противоречивых интерпретаций, что для двух разных людей «Clean Architecture» означает две разные системы. Это не один подход и не нарисованные Бобом Мартином кружочки в виде заповедей. Это семейство идей, которые сходятся в одном тезисе: бизнес-логика отделена от инфраструктуры, и зависимости текут только в одну сторону. Всё остальное — варианты реализации.
Базовая идея
Если убрать всю “архитектурную магию”, остаётся несколько очень простых правил:
бизнес-логика не должна зависеть от фреймворков
зависимости должны идти в одну сторону — внутрь к домену
код должен иметь границы, а не быть свалкой
база, HTTP, очереди — это просто детали, а не центр системы
Всё остальное — следствие нарушения этих правил.
Domain / Use Case / Infrastructure / Presentation
Чтобы перестать строить систему, которая через полгода превращается в легаси, нужно наконец-то ввести границы. Не «папки ради порядка», а такие, которые нельзя случайно нарушить — не пробив TypeScript, NestJS DI или собственную дисциплину команды.
Самая простая и рабочая модель:
Domain — что вообще происходит в бизнесе
Use Case — какие сценарии мы реализуем
Infrastructure — как это хранится и работает технически
Presentation — как в это всё заходят снаружи
Это не про «красивую структуру проекта», а про контроль над системой: без границ логика течёт куда попало, зависимости растут, код превращается в хаос. И это не вкусовщина — у любой архитектуры есть формальное описание через теорию графов, где модули и сервисы — вершины, а зависимости — рёбра. На таком графе строго видно, почему одна система деградирует, а другая остаётся управляемой. Clean Architecture в этом смысле — способ наложить ограничения на этот граф. С цифрами разберём в части 5.
Идём от простого к сложному — переделываем тот же проект с другой стороны, начиная с исходной структуры.
Старая структура проекта
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/
│ ├── typeorm/
│ └── migrations/
│
├── config/
│ └── configuration.ts
Сама структура неплохая, разделение по фичам — рабочий подход. Беда не в верхнем уровне, а в том, что у внутренностей модулей и их взаимодействий между собой нет правил, которые держали бы систему в форме через полгода работы.
Раньше внутренний модуль был устроен так
src/modules/tweets/
├── tweets.module.ts
├── tweets.controller.ts
├── tweets.service.ts
├── dto/
│ └── create-tweet.dto.ts
├── entities/
│ └── tweet.entity.ts
Как будем внедрять Clean Architecture?
Я предлагаю не отказываться от идеи разделять модули системы по фичам либо же по ключевым доменам проекта. У классического Clean Architecture существуют свои проблемы, которые тоже тяжело решать. Мы будем делать сообственно Feature Based Clean Architecture.
Как теперь будет устроен внутренний модуль
src/modules/tweets/
├── domain/
│ └── tweet.ts
│
├── use-case/
│ └── create-tweet/
│ ├── create-tweet.handler.ts
│ └── create-tweet.module.ts
│
├── infrastructure/
│ └── repositories/
│ ├── tweet.entity.ts
│ ├── tweet.repository.ts
│ └── tweet.repository.module.ts
│
└── presentation/
├── tweets-presentation.controller.ts
├── tweets-presentation.service.ts
├── tweets-presentation.module.ts
└── dto/
└── create-tweet.dto.ts
Структура буквально читается слоями.
Presentation —
presentation/. Транспорт: контроллер, DTO, presentation-service. Знает только про HTTP и про то, какой use-case дёрнуть.Use-case —
use-case/create-tweet/. Сценарий «создать твит»: оркестрация, проверки, вся бизнес-логика. То самое место, где в feature-based раздувалсяTweetsService.Infrastructure —
infrastructure/repositories/. Доступ к данным через репозиторий-абстракцию плюс ORM-сущность, которую видит только репозиторий.Domain —
domain/tweet.ts. Модель и её инварианты, без знания о том, где она хранится и как сюда попала.
Правило, которое всё это держит — зависимости направлены внутрь: presentation → use-case → infrastructure (через порт репозитория) → domain. Domain не знает ни про кого. Use-case не знает про HTTP. Infrastructure не знает про сценарий.
Начнем также с авторизации
@Controller("auth")
export class AuthPresentationController {
constructor(
private readonly authPresentationService: AuthPresentationService,
) {}
@Post("sign-up")
async signUp(@Body() dto: SignUpDto): Promise<SignUpResponse> {
return this.authPresentationService.signUp(dto.email, dto.password);
}
@Post("sign-in")
async signIn(@Body() dto: SignInDto): Promise<SignInResponse> {
return this.authPresentationService.signIn(dto.email, dto.password);
}
}
И вот тут — самый интересный вопрос, который в feature-based мы откладывали до тех пор, пока не становилось поздно: а как Auth достаёт юзера? В прошлой архитектуре AuthService.signUp спокойно вкалывал внутрь UsersService.create(...), потом тот в свою очередь дёргал что-нибудь ещё, и через пару итераций мы получали forwardRef, цикл и тот самый кейс из части 3. Здесь же SignUpHandler живёт внутри Auth, а данные про юзера лежат в Users. Между ними должна пройти граница — иначе всё, что мы тут построили, просто переедет внутрь use-case’а, и через пару спринтов handler начнёт импортировать UserRepository напрямую. Прежде чем рисовать эту границу — посмотрим на сам модуль Users.
src/modules/users/
├── domain/
│ └── user.ts
│
├── use-case/
│ ├── create-user/
│ │ ├── create-user.handler.ts
│ │ └── create-user.module.ts
│ │
│ └── get-user-by-email/
│ ├── get-user-by-email.handler.ts
│ └── get-user-by-email.module.ts
│
├── infrastructure/
│ └── repositories/
│ ├── user.entity.ts
│ ├── user.repository.ts
│ └── user.repository.module.ts
Как Auth будет общаться с Users, чтобы не повторить прошлые ошибки? Нужен контракт — и самый честный способ его обосновать заходит через микросервисы. В микросервисном мире давно живёт паттерн database per service: у каждого сервиса своя база, и сосед в твою таблицу не ходит, как бы ни хотелось. Из этого запрета сам собой рождается полезный побочный эффект — Auth и Users начинают общаться только через явный, ограниченный контракт, потому что иначе никак. Идея настолько хороша, что её жалко оставлять только за границей сети: можно перенести её внутрь монолита, не разводя зоопарка из реальных сервисов. Этот контракт и называется портом.
Порт — это интерфейс модуля, описывающий, что он отдаёт наружу — и больше ничего.
То есть не реализация, не репозиторий, не бизнес-сценарий. Это декларация — список операций и данных, доступных соседним модулям, как если бы за модулем стояла сетевая граница. Нейминг можно выбирать свой, важен смысл. Дальше — как этот порт ляжет в Users.
src/modules/users/
├── domain/
│ └── user.ts
│
├── use-case/
│ ├── create-user/
│ │ ├── create-user.handler.ts
│ │ └── create-user.module.ts
│ │
│ └── get-user-by-email/
│ ├── get-user-by-email.handler.ts
│ └── get-user-by-email.module.ts
│
├── infrastructure/
│ └── repositories/
│ ├── user.entity.ts
│ ├── user.repository.ts
│ └── user.repository.module.ts
│
├── external/ # Порт (контракт) модуля Users
│ ├── users-external.module.ts
│ └── users-external.service.ts
Важная деталь: ни наружу, ни внутри модуля entity не показывается — везде, где код выходит из репозитория, едет user.ts. Handler, который реально гоняет бизнес-логику, никогда не держит в руках UserEntity: репозиторий сам ходит в базу, мапит ряд в entity, превращает в доменный объект и отдаёт его дальше. Это значит, что use-case’у безразлично, какая ORM лежит под ним — TypeORM, Prisma, голый SQL — и какая там база. Так что если однажды захочется уйти с TypeORM на Prisma или с Postgres на Mongo — миграция упирается в инфраструктурный слой: переписать entity, переписать тело репозитория, поправить конфиг подключения. Бизнес-логика подмены не заметит. Если бы entity всплыла наружу — хоть через порт, хоть прямо в use-case через findOne() — вместе с ней наружу уехали бы и save(), и декораторы, и привязка к схеме таблицы; и любой соседний модуль начал бы менять данные в обход вашего порта.
// user.ts
export type User = {
id: string;
email: string;
password: string;
createdAt: Date;
};
// user.entity.ts
@Entity("users")
export class UserEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@CreateDateColumn()
createdAt: Date;
}
// user.repository.ts
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly repository: Repository<UserEntity>,
) {}
async create(data: CreateUserData): Promise<Result<User, CreateErrorCode>> {
const insertUserResult = await fromAsyncThrowable(async () =>
this.repository.insert(data),
)();
if (insertUserResult.isErr()) {
if (isUniqueQueryError(insertUserResult.error)) {
return err("CREATE_USER_CONFLICT");
}
return err("CREATE_USER_DATABASE_ERROR");
}
const now = new Date();
return ok({
id: insertUserResult.value.identifiers[0].id,
email: data.email,
password: data.password,
createdAt: now,
});
}
async findByEmail(
email: string,
): Promise<Result<User | undefined, FindErrorCode>> {
const findUserResult = await fromAsyncThrowable(async () =>
this.repository.findOne({ where: { email } }),
)();
if (findUserResult.isErr()) {
return err("FIND_USER_DATABASE_ERROR");
}
return ok(findUserResult.value ?? undefined);
}
}
Раз уж мы в контексте NestJS, надо подумать, как use-case и порты будут инжектить репозиторий. Правило простое: каждый репозиторий живёт в своём собственном модуле и подключается по одному, там, где нужен. Никаких UsersInfrastructureModule, который экспортирует сразу всё. Причина — в графе зависимостей. Когда get-user-by-email.module.ts явно пишет imports: [UserRepositoryModule], с одного взгляда понятно, что именно этому сценарию нужно. Когда там стоит общий UsersInfrastructureModule — непонятно ничего: handler может ходить в UserRepository, в UserProfileRepository, в UserSettingsRepository, и всё это невидимо для ревьюера. А через полгода окажется, что get-user-by-email тихо подтягивает три ненужных таблицы, потому что «всё равно из общего модуля приходит».
По сути мы тут берём идею Clean Architecture и опускаем её на уровень DI-фреймворка: те же границы между слоями, только теперь они выражены не через директорию, а через Nest-модуль. Правило «handler знает только про свои зависимости» перестаёт быть пунктом конвенции и становится механическим. Архитектура и DI-граф начинают совпадать.
// user.repository.module.ts
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [UserRepository],
exports: [UserRepository],
})
export class UserRepositoryModule {}
Теперь нужно вытащить наружу 2 метода createUser и getUserByEmail через порт, что бы auth смог ими воспользоваться. И тут стоит учесть, что напрямую метод репозитория через порт отдавать нельзя. Repository — это слой данных, в нём нет ни проверок, ни инвариантов, ни оркестрации; если порт зовёт его минуя use-case, любая бизнес-логика вокруг этого вызова просто негде разместить. Сегодня «получить юзера по email» — это один запрос. Завтра — запрос плюс проверка блокировки, кэш и трекинг. Всё это живёт в handler’е. Если порт ходит мимо — handler выключен из цепочки, и любая новая логика придётся либо в репозиторий (нарушение слоя), либо в порт (тоже). Поэтому нужен отдельный handler, который будет выполнять именно эту функцию.
// get-user-by-email.handler.ts
@Injectable()
export class GetUserByEmailHandler {
constructor(private readonly userRepository: UserRepository) {}
async run(
email: string,
): Promise<Result<User | undefined, GetUserByEmailHandlerErrorCode>> {
const findUserResult = await this.userRepository.findByEmail(email);
if (findUserResult.isErr()) {
return err("GET_USER_BY_EMAIL_DATABASE_ERROR");
}
return ok(findUserResult.value);
}
}
То же правило, что и для репозитория: один сценарий — свой модуль, свой экспорт. Тот, кому нужен GetUserByEmailHandler, импортирует ровно его, а не «все use-case’ы Users скопом».
// get-user-by-email.module.ts
@Module({
imports: [UserRepositoryModule],
providers: [GetUserByEmailHandler],
exports: [GetUserByEmailHandler],
})
export class GetUserByEmailModule {}
В итоге UsersExternalService работает как тонкий фасад: внутри держит handler’ы и пробрасывает в них вызовы, ничего не решая сам. Это и есть тот самый публичный контракт, ради которого мы вспоминали database per service. Соседние модули не видят ни UserRepository, ни внутренних handler’ов — они видят только тот набор операций, который Users явно выложил в external. Всё остальное концептуально находится «за сетевой границей», которой по факту нет, но которая есть в правилах.
// users-external.service.ts
@Injectable()
export class UsersExternalService {
constructor(
private readonly createUserHandler: CreateUserHandler,
private readonly getUserByEmailHandler: GetUserByEmailHandler,
) {}
async getUserByEmail(
email: string,
): Promise<Result<User | undefined, GetUserByEmailHandlerErrorCode>> {
return this.getUserByEmailHandler.run(email);
}
async createUser(
data: CreateUserData,
): Promise<Result<User, CreateUserHandlerErrorCode>> {
return this.createUserHandler.run(data);
}
}
Вернёмся к модулю Auth. Новая архитектура не даёт впихнуть сценарий куда попало — но и не подскажет, если вы воткнёте его не туда. Прежде чем показать правильное место, посмотрим на типичную ошибку: подключить UsersExternalService прямо в AuthPresentationService, в самом транспорте.
Один важный нюанс. Nest на этот случай не пожалуется: DI спокойно зарезолвит зависимость, потому что UsersExternalService экспортирован, а AuthPresentationService имеет право его импортировать. Запреты «кто кого может звать» — это не DI-граф, а правила слоёв; чтобы их ловила машина, нужен отдельный линтер вроде eslint-plugin-boundaries. Без него такие нарушения отлавливаются только глазами на ревью — что, как мы помним, проходит за тридцать секунд.
Плохое использование
@Injectable()
export class AuthPresentationService {
constructor(
private readonly jwtService: JwtService,
private readonly usersExternalService: UsersExternalService,
) {}
async signUp(
email: string,
password: string,
res: Response,
): Promise<SignUpResponse> {
const findUserResult =
await this.usersExternalService.getUserByEmail(email);
if (findUserResult.isErr()) {
throw new InternalServerErrorException();
}
if (findUserResult.value) {
throw new ConflictException("User already exists");
}
const createUserResult = await this.usersExternalService.createUser({
email,
password,
});
if (createUserResult.isErr()) {
throw new InternalServerErrorException();
}
const signTokenResult = await fromAsyncThrowable(async () =>
this.jwtService.signAsync({ sub: createUserResult.value.id }),
)();
if (signTokenResult.isErr()) {
throw new InternalServerErrorException();
}
res.cookie("accessToken", signTokenResult.value, {
httpOnly: true,
secure: true,
sameSite: "strict",
});
return {
id: createUserResult.value.id,
email: createUserResult.value.email,
};
}
async signIn(
email: string,
password: string,
res: Response,
): Promise<SignInResponse> {
const findUserResult =
await this.usersExternalService.getUserByEmail(email);
if (findUserResult.isErr()) {
throw new InternalServerErrorException();
}
if (!findUserResult.value) {
throw new UnauthorizedException("Invalid credentials");
}
if (findUserResult.value.password !== password) {
throw new UnauthorizedException("Invalid credentials");
}
const signTokenResult = await fromAsyncThrowable(async () =>
this.jwtService.signAsync({ sub: findUserResult.value.id }),
)();
if (signTokenResult.isErr()) {
throw new InternalServerErrorException();
}
res.cookie("accessToken", signTokenResult.value, {
httpOnly: true,
secure: true,
sameSite: "strict",
});
return {
id: findUserResult.value.id,
email: findUserResult.value.email,
};
}
}
Почему так нельзя? Потому что бизнес-логика всё ещё живёт в AuthPresentationService. Вы убрали прямой доступ к репозиторию, но не убрали главное — концентрацию сценариев в одном месте.
К чему это приведёт:
В
AuthPresentationServiceначнут стекаться все сценарии: sign-up, sign-in, refresh, logout, social login, 2FA, recovery.К нему подтянутся зависимости:
Users,Tokens,Sessions,Email,AntiFraud,Analytics.Сервис снова превратится в оркестратор всего подряд, хотя его первостепенная задача — обслуживать транспортный слой.
Через пару итераций вы получите ровно то, что было в feature-based. Неважно, через что внутри ходит код — UserRepository или UsersExternalService. У вас снова один класс, который знает слишком много и тащит на себе всю систему.
Правильный вариант
src/modules/auth/
├── use-case/
│ ├── sign-up/
│ │ ├── sign-up.handler.ts
│ │ └── sign-up.module.ts
│ │
│ └── sign-in/
│ ├── sign-in.handler.ts
│ └── sign-in.module.ts
│
├── presentation/
│ ├── auth-presentation.controller.ts
│ ├── auth-presentation.service.ts
│ └── dto/
│ ├── sign-up.dto.ts
│ └── sign-in.dto.ts
Нам нужен handler, которому безразлично, кто его вызвал. На входе — параметры сценария, на выходе — результат, между ними — бизнес-логика. Без HTTP-обёртки, без знания об Express, Fastify или о том, что вообще существует контроллер. Это даёт две конкретные вещи. Во-первых, протестировать сценарий теперь можно одним вызовом handler.run(...) — никаких моков HTTP-стека, никаких e2e-обёрток. Во-вторых, если завтра поверх REST-API появится GraphQL-схема или gRPC-сервис, переписывать придётся ровно presentation-слой; handler останется как есть.
@Injectable()
export class SignUpHandler {
constructor(
private readonly jwtService: JwtService,
private readonly usersExternalService: UsersExternalService,
) {}
async run(
email: string,
password: string,
): Promise<Result<SignUpResult, SignUpErrorCode>> {
const findUserResult =
await this.usersExternalService.getUserByEmail(email);
if (findUserResult.isErr()) {
return err("SIGN_UP_GET_USER_FAILED");
}
if (findUserResult.value) {
return err("SIGN_UP_USER_ALREADY_EXISTS");
}
const createUserResult = await this.usersExternalService.createUser({
email,
password,
});
if (createUserResult.isErr()) {
return err("SIGN_UP_CREATE_USER_FAILED");
}
const signTokenResult = await fromAsyncThrowable(async () =>
this.jwtService.signAsync({ sub: createUserResult.value.id }),
)();
if (signTokenResult.isErr()) {
return err("SIGN_UP_TOKEN_SIGN_FAILED");
}
return ok({
user: createUserResult.value,
accessToken: signTokenResult.value,
});
}
}
А теперь — обратно в presentation. Здесь живёт код, который явно знает про транспорт: в нашем случае это HTTP через Express, и это значит — заголовки, cookies, статусы, всё, что относится конкретно к этому каналу связи.
@Injectable()
export class AuthPresentationService {
constructor(private readonly signUpHandler: SignUpHandler) {}
async signUp(dto: SignUpDto, res: Response): Promise<SignUpResponse> {
const signUpResult = await this.signUpHandler.run(dto.email, dto.password);
if (signUpResult.isErr()) {
if (signUpResult.error === "SIGN_UP_USER_ALREADY_EXISTS") {
throw new ConflictException("User already exists");
}
throw new InternalServerErrorException();
}
res.cookie("accessToken", signUpResult.value.accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
});
return {
id: signUpResult.value.user.id,
email: signUpResult.value.user.email,
};
}
}
Конечно, можно держать cookie-логику и прямо в контроллере — формально это та же presentation-зона, и @Controller спокойно установит cookies через @Res() res. Это вкусовщина, но я предпочитаю, чтобы контроллер читался как карта: список ручек, их пути, входные DTO, Guards, интерцепторы — и больше ничего. Всё, что относится к телу обработки — пакование cookies, сборка ответа, мапинг исключений в HTTP-статусы — уезжает в presentation-service. Контроллер остаётся декларацией поверхности модуля; presentation-service — местом, где она реализуется.
@Controller("auth")
export class AuthPresentationController {
constructor(
private readonly authPresentationService: AuthPresentationService,
) {}
@Post("sign-up")
async signUp(
@Body() dto: SignUpDto,
@Res({ passthrough: true }) res: Response,
): Promise<SignUpResponse> {
return this.authPresentationService.signUp(dto, res);
}
}
Давайте теперь посмотрим на наш граф зависимостей.
Почему такой подход помогает архитектуре дольше держать форму? Не потому что Nest за этим следит — он не следит, он просто DI-фреймворк, и про слои он знает не больше, чем tsc. Защита держится на трёх вещах, ни одна из которых не идёт из коробки.
Конвенция. Каждый модуль публикует наружу только *External*Service; handler’ы, репозитории, домен — нигде не экспортируются. Это можно нарушить, Nest спокойно отдаст любой провайдер, который ты решишь экспортировать. Но шорткат «открою-ка GetUserByEmailHandler для Auth» оставляет след: меняется users-external.module.ts, добавляется экспорт, импортируется в Auth-handler. Каждый из этих файлов попадает в diff и на ревью.
Линтер. eslint-plugin-boundaries или dependency-cruiser позволяет описать «auth/ импортирует только из users/external/*» — и ловить нарушения в IDE до коммита. Это и есть тот слой, где архитектурное правило становится проверяемым на сборке. Опционально, но именно он переводит дисциплину из «договорились» в «не пройдёт».
Ревью. Когда линтера нет, остаются глаза. Без линтера FBCA-структура помогает не «не деградировать», а «деградировать заметнее»: каждый шорткат теперь оставляет diff, который выглядит подозрительно. Это снижает шансы, но и не до нуля.
В сумме: новая фича аккуратно ложится в новый модуль легче, чем небрежно, — не потому что небрежно нельзя, а потому что небрежно теперь видно. Через полгода именно эта асимметрия определяет, как быстро команда добавляет фичи.
Для сравнения, давайте посмотри как бы выглядел граф зависимостей feature-based на этом этапе развития.
Он выглядит намного проще, поэтому тяжело сразу понять настоящий смысл Clean Architecture. Обманчивость FB-графа в одной вещи: его рисуют новые проекты. Легаси, в котором AuthService оброс десятью зависимостями и forwardRef’ом, графов своих не публикует — он и так слишком известен команде, чтобы его рисовать.
Поэтому когда новичок видит сравнение «вот FB — три класса и стрелка», «вот FBCA — десять классов и кластеры», он смотрит на ту фазу, в которой FB действительно выигрывает: первый месяц проекта. Это survivorship bias в чистом виде: статичные сравнения почти всегда показывают момент, когда ещё ничего не успело сломаться.
Понимание Clean Architecture приходит не сразу, когда она кажется громоздкой, а через год — когда оказывается, что она не развалилась. И именно эту разницу тяжело почувствовать заранее: люди видят сегодняшнюю стоимость, и им трудно поверить, что инвестиция себя окупит.
В слеующей части я наглядно покажу, почему feature-based начала деградировать, а feature-based-clean продолжит своё существование очень долго.























