惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
C
Check Point Blog
博客园_首页
B
Blog
D
Docker
U
Unit 42
量子位
I
InfoQ
有赞技术团队
有赞技术团队
Martin Fowler
Martin Fowler
GbyAI
GbyAI
L
LangChain Blog
云风的 BLOG
云风的 BLOG
博客园 - Franky
美团技术团队
T
The Blog of Author Tim Ferriss
阮一峰的网络日志
阮一峰的网络日志
月光博客
月光博客
Vercel News
Vercel News
Recent Announcements
Recent Announcements
雷峰网
雷峰网
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
Google DeepMind News
Google DeepMind News

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Feature Based Clean Architecture. Часть 4: FBCA: формализ...
shkvik · 2026-05-23 · via Все публикации подряд на Хабре

Время на прочтение14 мин

Охват и читатели0

Аналитика

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

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

Граф зависимостей 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 продолжит своё существование очень долго.