Feature Based Clean Architecture. Часть 4: FBCA: формализация границ ответственности в NestJS-модуле

от автора

Архитектурная доктрина для 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

Структура буквально читается слоями.

  • Presentationpresentation/. Транспорт: контроллер, DTO, presentation-service. Знает только про HTTP и про то, какой use-case дёрнуть.

  • Use-caseuse-case/create-tweet/. Сценарий «создать твит»: оркестрация, проверки, вся бизнес-логика. То самое место, где в feature-based раздувался TweetsService.

  • Infrastructureinfrastructure/repositories/. Доступ к данным через репозиторий-абстракцию плюс ORM-сущность, которую видит только репозиторий.

  • Domaindomain/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);  }}

Давайте теперь посмотрим на наш граф зависимостей.

Граф зависимостей FBCA: модули Auth и Users

Граф зависимостей FBCA: модули Auth и Users

Почему такой подход помогает архитектуре дольше держать форму? Не потому что 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 на этом этапе развития.

Граф зависимостей FBCA: модули Auth и Users

Граф зависимостей FBCA: модули Auth и Users

Он выглядит намного проще, поэтому тяжело сразу понять настоящий смысл Clean Architecture. Обманчивость FB-графа в одной вещи: его рисуют новые проекты. Легаси, в котором AuthService оброс десятью зависимостями и forwardRef’ом, графов своих не публикует — он и так слишком известен команде, чтобы его рисовать.

Поэтому когда новичок видит сравнение «вот FB — три класса и стрелка», «вот FBCA — десять классов и кластеры», он смотрит на ту фазу, в которой FB действительно выигрывает: первый месяц проекта. Это survivorship bias в чистом виде: статичные сравнения почти всегда показывают момент, когда ещё ничего не успело сломаться.

Понимание Clean Architecture приходит не сразу, когда она кажется громоздкой, а через год — когда оказывается, что она не развалилась. И именно эту разницу тяжело почувствовать заранее: люди видят сегодняшнюю стоимость, и им трудно поверить, что инвестиция себя окупит.

В слеующей части я наглядно покажу, почему feature-based начала деградировать, а feature-based-clean продолжит своё существование очень долго.

ссылка на оригинал статьи https://habr.com/ru/articles/1038438/