Clean Architecture и AI: как я перестроил проект на 200К строк, чтобы агенты не ломали код

от автора

Полгода назад я открыл pull request от Claude Code и завис над одной функцией. Она работала, тесты были зелёные, ревьюер из команды поставил апрув. А я не мог в неё внести правку, не подняв в голове граф из восемнадцати файлов. Сервис уведомлений тянул зависимости из биллинга, из профиля, из аналитики и ещё из десятка мест. Формально чисто. По факту это был бетон.

Тогда я сформулировал мысль, которая дальше определила полгода работы: агент не ломает архитектуру. Он заливается в те щели, которые архитектура ему оставила. Где есть граница, он останавливается. Где границы нет, он соединяет всё со всем, потому что так короче и тесты всё равно проходят.

Это статья про то, как я перестроил TypeScript-проект на 200 тысяч строк, чтобы Claude Code приносил поддерживаемый код, а не работающий бетон. С конкретными правилами, цифрами до и после, и честным разделом про то, где Clean Architecture начинает мешать самому же агенту.

Коротко о себе: я Full-Stack JS архитектор, стек TypeScript, Node.js, NestJS, Next.js, React Native. Разрабатываю с AI в продакшене с 2024 года. Тот самый проект на 200К строк я год назад мигрировал с JS на strict TS, и про это писал отдельно. Тут речь про следующий этап, когда миграция закончилась, а агент остался в ежедневной работе.

1. Почему агент ломает код именно там, где слабая структура

У человека и у агента разная стоимость связности. Когда я пишу функцию руками и тяну в неё пятую зависимость, я физически чувствую трение. Надо вспомнить, где лежит модуль, импортировать, не запутаться в циклах. Это трение работает как тормоз, я подсознательно ищу способ не плодить связи.

У агента этого тормоза нет. Ему одинаково дёшево импортировать из соседнего файла и из модуля на другом конце проекта. Ему так даже выгоднее. Он видит задачу локально: «добавь отправку письма при смене тарифа». Самый короткий путь к зелёным тестам это дёрнуть готовый сервис писем прямо из биллинга. Агент так и делает. Он оптимизирует под «работает сейчас», а не под «это можно будет менять через полгода».

Я называю это когнитивной асимметрией. У агента нет глобальной модели проекта в голове, он держит в контексте только то, что ему подсунули. Поэтому он не отличает правильную зависимость от случайной. Для него и то и другое просто импорт.

Поэтому бесполезно ждать, что агент сам будет беречь архитектуру. У него нет органа, которым её чувствуют. Её надо сделать видимой и проверяемой, тогда агент в неё упрётся.

2. Граница модуля как контракт, а не как папка

Первое, что я сделал, перестал считать папку модулем. Папка это просто способ сложить файлы. Модуль это контракт: что он отдаёт наружу и что прячет внутри.

В нашем проекте каждый модуль теперь имеет единственную точку входа, index.ts, через которую торчит публичный API. Всё остальное внутри это приватная кишка, к которой снаружи доступа нет. Не «не желательно», а нет физически, это ловит линтер.

modules/  billing/    index.ts            // публичный API: то что можно импортировать    application/        // юзкейсы    domain/             // сущности и правила    infrastructure/     // адаптеры, репозитории  notifications/    index.ts    ...

Раньше агент писал import { TariffEntity } from '../../billing/domain/tariff.entity'. Лез руками во внутренности чужого модуля. Теперь так нельзя, наружу торчит только import { getTariff } from 'modules/billing'. Если в публичном API нужной функции нет, агент обязан либо использовать существующий контракт, либо явно его расширить. И вот это «явно расширить» уже видно на ревью, потому что меняется index.ts, а не прячется в глубине дерева импортов.

Эффект был мгновенный. Случайные связи между модулями почти исчезли, потому что для случайной связи теперь надо совершить заметное действие. А заметное действие агент совершает только когда я его об этом прошу.

Если коротко, граница, которую видно в одном файле и которую проверяет линтер, для агента сильнее ста слов про «соблюдай инкапсуляцию» в CLAUDE.md.

3. Правило зависимостей, которое я в итоге зашил в линтер

Текстовые правила в CLAUDE.md работают плохо, когда они абстрактные. «Минимизируй связность» агент понимает как угодно. Поэтому я перевёл архитектурные принципы в конкретные числа, которые можно проверить машиной.

Три правила, которые дали больше всего:

  1. Не больше трёх внешних зависимостей в одном юзкейсе. Если их четыре, скорее всего юзкейс делает две вещи и его надо разбить.

  2. Запрет на импорт глубже одного уровня в чужой модуль. Только через публичный index.ts.

  3. Никаких импортов из infrastructure в domain. Зависимости смотрят внутрь, к доменным правилам, а не наружу.

Самое важное, что эти правила я не оставил в виде пожеланий. Они живут в dependency-cruiser и в eslint-конфиге, и нарушение валит CI. Для агента это меняет всё. Когда правило это текст, агент его обходит, как только текст вступает в конфликт с «сделать задачу». Когда правило это красный пайплайн, агент видит ошибку прямо в цикле и сам себя правит, ещё до того как код доходит до меня.

// dependency-cruiser, фрагмент{  name: 'domain-stays-pure',  severity: 'error',  from: { path: 'modules/[^/]+/domain' },  to:   { path: 'modules/[^/]+/infrastructure' }}

Архитектурное правило стоит ровно столько, насколько оно исполняемо. Текст агент трактует, упавший CI он чинит.

4. Слои, и куда агент тянется по умолчанию

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

Если оставить агента без слоёв, он по умолчанию кладёт бизнес-логику туда, где она ближе к запросу. То есть в контроллер. Я сто раз видел, как агент пишет проверку прав, расчёт скидки и запись в базу прямо в обработчике HTTP. Работает, тесты есть. Только бизнес-правило теперь приклеено к HTTP, и переиспользовать его из фоновой задачи нельзя, и протестировать без поднятия половины фреймворка тоже.

Вот как выглядит типичный результат агента без слоёв. Всё в одном обработчике:

@Post('subscribe')async subscribe(@Body() dto: SubscribeDto) {  const user = await this.repo.findUser(dto.userId);  // правило прямо в контроллере  const discount = user.isReturning && dto.plan === 'pro' ? 0.2 : 0;  const price = PLANS[dto.plan].price * (1 - discount);  await this.repo.saveSubscription({ userId: user.id, plan: dto.plan, price });  await this.mailer.send(user.email, 'subscribed'); // ещё и письмо здесь  return { ok: true };}

Работает, тест есть. Но правило про скидку приклеено к HTTP, переиспользовать его из ночной задачи нельзя, протестировать без поднятия контроллера тоже.

Я развёл это на три слоя внутри каждого модуля. domain это сущности и правила, чистый TypeScript без единого импорта фреймворка. application это юзкейсы, они оркестрируют домен. infrastructure это адаптеры к базе, очередям, внешним API. Контроллер стал тонким, он только разбирает запрос и зовёт юзкейс. Та же логика после разводки:

// domain/discount.ts — правило, чистый TS, ноль фреймворкаexport function subscriptionPrice(user: User, plan: Plan): Money {  const discount = user.isReturning && plan.id === 'pro' ? 0.2 : 0;  return plan.price.times(1 - discount);}// application/subscribe.usecase.ts — сценарийasync execute(cmd: SubscribeCommand) {  const user = await this.users.byId(cmd.userId);  const price = subscriptionPrice(user, PLANS[cmd.plan]);  await this.subscriptions.create(user.id, cmd.plan, price);  await this.events.emit(new Subscribed(user.id));}// контроллер теперь тонкий@Post('subscribe')subscribe(@Body() dto: SubscribeDto) {  return this.subscribe.execute(dto);}

Правило про скидку стало функцией без зависимостей, его можно дёрнуть откуда угодно и проверить одним юнит-тестом. Письмо ушло за событие, контроллер больше ничего не знает про почту.

Для агента слои это карта, куда что класть. В CLAUDE.md у меня буквально написано: расчёты и правила в domain, сценарий в application, всё что про внешний мир в infrastructure, контроллер не содержит логики. С этой картой агент перестал сваливать всё в одно место. Не идеально, иногда промахивается, но промах теперь видно сразу, потому что в domain появляется импорт @nestjs/common, а это запрещено линтером из прошлого раздела.

Короче, слои нужны не ради чистоты ради чистоты. Они дают агенту понятные адреса, куда складывать код, и делают промахи заметными.

5. Где всё это живёт: CLAUDE.md плюс структура плюс линтер

Долго я пытался удержать архитектуру одним только CLAUDE.md. Это была ошибка. Файл рос, к восьми тысячам слов он стал работать хуже, чем на двух тысячах. Агент перегружался, начинал игнорировать середину.

Сейчас у меня нагрузка распределена на три слоя, и каждый делает свою часть.

Структура папок несёт правила, которые не нужно объяснять словами. Видишь domain, application, infrastructure, и уже понятно, что куда. Агент тоже это считывает, он отлично работает по аналогии с тем, что уже есть в проекте.

Линтер несёт правила, которые надо проверять на каждом коммите. Границы модулей, направление зависимостей, лимит связей. Это то, что нельзя доверить тексту, потому что текст обходится.

CLAUDE.md несёт то, что нельзя вывести из структуры: зачем мы так сделали, что у нас однажды сломалось, какие правила приоритетнее. Он стал коротким и состоит в основном из примеров, а не запретов. Про примеры дальше отдельный раздел.

Главный сдвиг в мышлении: CLAUDE.md это не место для всех правил. Это место только для тех правил, которые невозможно закодировать в структуре или в линтере. Всё остальное должно стать кодом, потому что код агент не игнорирует.

Архитектура для агента это не документ. Это система из трёх слоёв, где структура показывает, линтер проверяет, а CLAUDE.md объясняет то, что не выводится из первых двух.

6. Антипаттерны вместо запретов

Отдельно про то, как формулировать правила, чтобы агент их применял.

Долго я писал в стиле «никогда не делай X». «Никогда не обращайся к базе из контроллера». «Не используй any». Эти запреты агент нарушал чаще всего. Причина, как мне кажется, в ассоциативности модели: она реагирует на то, что в тексте есть, а не на отрицание. «Никогда не делай X» подсвечивает X.

Я переписал почти все запреты в позитивные примеры с историей. Не «никогда не клади логику в контроллер», а «вот случай: мы положили расчёт скидки в контроллер, потом понадобилось пересчитать скидку в ночной задаче, пришлось дублировать. С тех пор расчёты живут в domain». Конкретный обвалившийся случай работает лучше абстрактного запрета, потому что он даёт агенту модель ситуации, а не голую границу.

На практике один и тот же принцип в CLAUDE.md выглядит так. Было:

## Правила- Никогда не обращайся к базе из контроллера.- Не клади бизнес-логику в обработчики HTTP.

Стало:

## Где живёт логикаРасчёты и правила держим в domain, отдельной функцией без зависимостей.Почему: однажды расчёт скидки лежал в контроллере subscribe. Когдапонадобилось пересчитать скидку в ночной задаче, логику пришлоськопировать, и две копии разъехались за месяц. Теперь правило живётв domain/discount.ts и зовётся из обоих мест.

Второй вариант длиннее, но агент по нему кладёт код в нужный слой заметно чаще. Он реагирует на конкретную историю про разъехавшиеся копии, а не на абстрактное слово «никогда».

Точной статистики по каждому правилу я не вёл, врать не буду. Но ощущение от ежедневной работы поменялось сильно. После перехода с запретов на истории про сломанное агент стал чаще попадать в нужный слой с первого раза, и переделок стало в разы меньше.

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

7. Тесты как контракт на границах, а не покрытие ради процента

Когда у модулей появились чёткие публичные API, тесты встали на своё место сами. Я перестал гнаться за общим процентом покрытия и начал покрывать границы.

На каждый публичный контракт модуля есть тесты, которые описывают его поведение со стороны потребителя. Не «как устроено внутри», а «что обещает наружу». Это даёт две вещи. Во-первых, агент при изменении внутренностей модуля сразу видит, не сломал ли он обещание наружу. Во-вторых, такие тесты это исполняемая спецификация контракта, и агент по ним отлично достраивает реализацию.

Внутренности модуля я покрываю там, где есть нетривиальная доменная логика, и почти не покрываю тонкие адаптеры. Это сместило усилия туда, где тесты реально ловят регрессии, а не туда, где они просто поднимают цифру.

Важный нюанс про агента и тесты. Без контракта на границе агент любит зеленить тест, который сам же и придумал под свою реализацию. Получается замкнутый круг, тест проверяет ровно то, что код делает, и ничего не ловит. Контрактный тест на публичном API я пишу или ревьюю сам, и он становится той внешней точкой опоры, которую агент не может подкрутить под себя.

Покрывайте границы модулей контрактами, которые описывают обещания наружу. Это и регрессии ловит, и даёт агенту честную спецификацию, которую он не перепишет под удобную реализацию.

8. Что измерилось: до и после

Теперь цифры, ради которых всё затевалось. Замеры по нашему проекту, период примерно полгода, до перестройки и после того, как структура, линтер и формат правил устоялись.

Среднее число межмодульных импортов на типичную фичу упало примерно втрое. Раньше новая фича цепляла соседние модули почти всегда, теперь в большинстве случаев она живёт в своём модуле и общается с другими через узкие контракты.

Время на типичную правку в коде, которому пара месяцев, сократилось ощутимо. Раньше, чтобы поменять поведение, я сначала час распутывал, кто на кого ссылается. Теперь правка чаще локальна в пределах одного модуля, и распутывать почти нечего.

Доля pull request от агента, которые я отправлял на существенную переделку из-за архитектуры, упала с примерно каждого второго до примерно каждого пятого. Это самый важный для меня показатель, потому что переделка съедает больше всего времени.

И отдельно про контекст. Когда у кода есть границы, агенту для задачи нужно затащить в контекст один модуль, а не половину проекта. Размер контекста на типичную задачу заметно уменьшился, а вместе с ним упала и цена ошибки, потому что агент работает на меньшем и более релевантном куске.

Я сознательно даю относительные цифры, а не красивые точные проценты. Точные проценты в таких замерах почти всегда подгонка. Порядок величины честный: связность в разы меньше, переделок в разы меньше, контекст заметно меньше.

Эффект архитектуры на работу с агентом меряется не в красоте, а в стоимости каждой следующей правки. У нас эта стоимость упала в разы.

9. Где Clean Architecture начинает мешать агенту

Честный раздел, без него статья была бы рекламой. Архитектура это не бесплатно, и в паре мест она агенту реально мешает.

Первое, оверинжиниринг на мелких фичах. Если задача это добавить одно поле в форму и провести его до базы, три слоя и контракт превращаются в пять файлов ради одного поля. Агент послушно плодит эти пять файлов, и получается церемония на ровном месте. Я для таких случаев держу в CLAUDE.md явное разрешение: если фича не несёт доменной логики, можно тонким путём, без полного набора слоёв. Без этого разрешения агент применяет тяжёлую структуру везде, потому что она прописана как дефолт.

Второе, абстракции, которые агент понимает буквально. Если в проекте есть хитрый обобщённый механизм, скажем самописный слой для работы с очередями с кучей дженериков, агент часто им давится. Он либо копирует его неправильно, либо обходит стороной и пишет своё рядом. Слишком умная абстракция для агента такой же барьер, как и для нового джуна в команде. Чем прямолинейнее код на границе, тем лучше агент с ним работает.

Третье, цена входа. Всё это надо построить и поддерживать. Линтер-правила, структура, контрактные тесты это работа, которая не пишет фич напрямую. На маленьком проекте или прототипе она не окупится, там трение от архитектуры будет больше, чем польза. Я бы не тащил весь этот аппарат в проект, которому неделя от роду.

Clean Architecture окупается на коде, который живёт долго и который много меняют. На прототипе и на мелких бесфичовых правках она превращается в налог, и агенту надо явно разрешать лёгкий путь.

10. Чек-лист, чтобы агент не ломал архитектуру

Если убрать всё лишнее, остаётся короткий список. Это то, что я бы сделал, заходя в новый долгоживущий проект, где в ежедневной работе будет агент.

  1. Каждый модуль отдаёт наружу один публичный API через index.ts, остальное приватно.

  2. Запрет на импорт внутренностей чужого модуля проверяется линтером, а не словами.

  3. Направление зависимостей зафиксировано: domain ничего не знает про infrastructure, проверяется линтером.

  4. Лимит на число зависимостей в юзкейсе, превышение валит CI.

  5. Слои внутри модуля: domain, application, infrastructure, контроллер тонкий.

  6. Правила в CLAUDE.md в формате «вот что у нас сломалось», а не «никогда не делай».

  7. CLAUDE.md короткий, в нём только то, что не выводится из структуры и линтера.

  8. Контрактные тесты на публичных API модулей, их пишет или ревьюит человек.

  9. Явное разрешение на лёгкий путь для фич без доменной логики.

  10. Абстракции на границах простые, без героических дженериков.

Главная мысль под всем этим одна. Агент не уважает текст, но уважает упавший пайплайн и уважает пример того, что уже работает в проекте. Поэтому архитектуру для агента надо не описывать, а делать видимой и исполняемой. Структура показывает, линтер проверяет, тесты держат контракт, а CLAUDE.md объясняет только то, что нельзя вывести из первых трёх.

Полгода назад я завис над функцией, в которую не мог внести правку. Сейчас таких функций в проекте почти нет, и не потому что агент стал умнее. Он ровно такой же. Просто теперь ему негде разлиться.


Если тема близка, я пишу про реальный опыт работы с Claude Code в продакшене в Telegram-канале «AI-усиленный разработчик», там разборы покороче и почаще. Ссылка в профиле. В комментариях интересно, какие границы у вас держит линтер, а какие пока только на словах в CLAUDE.md.

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