Архитектурная доктрина для 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.tsexport 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 продолжит своё существование очень долго.
ссылка на оригинал статьи https://habr.com/ru/articles/1038438/