Полгода назад я начал писать пет-проект практически полностью с помощью LLM-агентов. Первые недели это был чистый дофамин: фичи вылетали за вечер, я успевал за неделю столько, сколько раньше делала небольшая команда. А потом всё начало вязнуть — ровно так же, как вязнет любой проект с говнокодом. Каждая новая фича ломала две старые, агент правил код «на ощупь», а я всё больше времени тратил не на продукт, а на разгребание.
Эта статья — про то, что я понял и что в итоге сработало: почему LLM деградирует на плохой архитектуре точно так же, как команда людей; почему классика (SOLID, DDD, чистая архитектура) не только не устарела, но стала важнее; и как превратить архитектурные договорённости из «пожеланий в CLAUDE.md» в контракт, который машина не может нарушить. С конкретикой: два конфига deptrac для Symfony-бакенда и пайплайн разработки с субагентом-ревьювером.
Медовый месяц и похмелье
Проект — сервис AI-обработки изображений: пользователь загружает фото, выбирает инструмент, платит кредитами, получает результат. Symfony на бакенде, Next.js на фронте, ничего экзотического.
Первый месяц vibe coding выглядел как реклама будущего. «Добавь оплату кредитами» — готово. «Сделай очередь обработки с ретраями» — готово. Я почти не читал код, только проверял, что работает.
К третьему месяцу картина изменилась:
-
Агент добавляет проверку баланса — и она оказывается четвёртой реализацией этой проверки в проекте, чуть-чуть отличающейся от трёх предыдущих.
-
Правка в модуле обработки фото ломает админку, потому что они, оказывается, делят одну общую «помойку» — сервис на полторы тысячи строк.
-
Каждый диалог с агентом начинается с десяти минут «изучения кодовой базы», и всё равно он регулярно промахивается.
Знакомо? Это же классическая кривая легаси-проекта. Скорость внедрения фич падает по той же траектории, что и у команды людей — просто у LLM весь цикл «от стартапа до болота» занимает не три года, а три месяца.
И это логично, если вспомнить, на чём модель училась. LLM обучен на человеческом коде с GitHub — включая весь говнокод, которого там статистически больше, чем хорошего кода. Модель не знает «как правильно», она знает «как обычно». А «как обычно» — это god-объекты, копипаста и логика, размазанная по слоям.
Контекстное окно — это рабочая память джуна
Ключевая механика деградации простая.
LLM видит только то, что влезло в контекст. Чтобы безопасно изменить код, агент должен «загрузить в голову» всё, что этим кодом затронуто. И тут вступает связность:
-
Низкий coupling: правка локальна, агенту хватает одного-двух файлов, он делает изменение уверенно и правильно.
-
Высокий coupling: чтобы понять последствия правки, нужно прочитать 20 файлов. Агент читает 5, про остальные не догадывается — и правит на ощупь. Что-то ломается в месте, которое в контекст не попало.
Отсюда главный тезис статьи:
Архитектура — это не эстетика. Это способ минимизировать объём контекста, необходимый для одного изменения.
Хорошая архитектура делает изменения локальными. Локальные изменения влезают в контекстное окно целиком. Значит, LLM-friendly код — это ровно тот же код, который friendly для новичка в команде: понятный за пять минут, а не за две недели онбординга. Всё, что мы двадцать лет рассказывали друг другу про качество кода «для будущих поколений разработчиков», внезапно стало иметь мгновенный, измеримый эффект: либо агент справляется с фичей за итерацию, либо вы платите токенами и своим временем за его блуждания.
SOLID, DRY, KISS, YAGNI — теперь с LLM-оптикой
Классические принципы никуда не делись, но у каждого появилось новое прочтение.
SRP. Класс с одной ответственностью — маленький. Маленький класс целиком влезает в контекст вместе со своими тестами. Правка не задевает чужую ответственность, потому что чужой ответственности в файле нет. God-объект же гарантированно не влезет в голову агента вместе со всеми своими использованиями.
OCP + DIP. Если новая фича — это новый класс, реализующий существующий интерфейс, агенту вообще не нужно трогать старый код. Он смотрит на интерфейс и одну соседнюю реализацию как на образец — и пишет новую. Риск регрессии около нуля. Если же новая фича — это if в десяти местах, агент найдёт восемь из них.
DRY. У LLM патологическая тяга к копипасте. Ему всегда «дешевле» сгенерировать проверку заново, чем найти существующую: генерация — это его естественный режим, а поиск по кодовой базе — дорогая операция, съедающая контекст. Без внешнего контроля вы получите пять версий одной валидации, и при изменении бизнес-правила агент обновит три из них.
KISS + YAGNI. А вот тут обратная беда. LLM обожает «на всякий случай»: фабрику для класса с одной реализацией, конфигурируемость, которую никто не просил, слой абстракции над слоем абстракции. Выглядит солидно, ревью проходит легко («ну, аккуратно же написано»), а через месяц эта преждевременная гибкость становится тем самым лишним контекстом, который мешает следующим правкам.
Из этого следует неприятный вывод: знать принципы самому уже недостаточно. Когда код пишет агент со скоростью тысячи строк в час, ревьюить его глазами на соответствие архитектуре невозможно — вы станете бутылочным горлышком или начнёте пропускать. Принципы нужно кодифицировать так, чтобы их проверяла машина. К этому и придём.
DDD: бакенду — да, фронтенду — нет
Прежде чем перейти к инструментам — два слова про доменно-ориентированное проектирование, потому что именно оно даёт LLM самое ценное: карту.
Ограниченные контексты и единый язык — это, по сути, способ разбить систему на куски, каждый из которых можно понять изолированно. Для агента неймспейс Core\Billing\Domain\Entity\CreditBalance — это уже документация: понятно, что это, где живёт и кого касается, ещё до чтения кода. Ubiquitous language работает с LLM даже лучше, чем с людьми: модель отлично оперирует словами, и если код называет вещи так же, как их называет постановка задачи, полдела сделано.
Но выстраданное уточнение: DDD — это про бакенд. Именно там живёт то, ради чего DDD придуман: инварианты, транзакции, бизнес-правила, согласованность данных.
Я честно пробовал тащить ту же структуру на фронтенд — entities, репозитории, use cases в React-приложении. Получился оверинжиниринг в чистом виде: слои ради слоёв. Фронтенд — это отображение и UX-состояние. Его домен — «что сейчас на экране и что произойдёт по клику», а не «может ли пользователь потратить кредиты». Бизнес-инварианты фронту доверять всё равно нельзя — их проверит бакенд. Фронту хватает компонентного подхода или feature-sliced design, а агенту на фронте нужна не доменная модель, а типизированный API-клиент, сгенерированный из контрактов бакенда.
(Дисклеймер для комментариев: да, существуют rich-client приложения с настоящей доменной логикой на клиенте — графические редакторы, офисные пакеты. Речь про типичные продуктовые SPA и мобильные приложения.)
Пример: модульный монолит на Symfony
Дальше конкретика. Бакенд проекта устроен так:
apps/backend/├── core/ # framework-agnostic бизнес-логика│ ├── Auth/│ ├── Billing/ # кредиты, тарифы, платежи│ ├── Media/ # хранение файлов│ ├── Task/ # оркестрация AI-пайплайна│ ├── Tool/ # каталог инструментов обработки│ ├── Monitoring/ # здоровье AI-провайдеров│ ├── Shared/ # примитивы: Money, Uuid, Clock...│ └── Port/ # ICommandBus, IQueryBus, IJobDispatcher└── src/ # Symfony-адаптеры ├── Api/ # тонкие HTTP-контроллеры └── Infrastructure/ # реализации портов: Doctrine, S3, шины
Каждый контекст внутри повторяет одну и ту же структуру:
core/Billing/├── Domain/ # Entity, ValueObject, Enum, Exception, Repository (интерфейсы)├── Application/ # Command + Handler, Query + Handler, Job, View (DTO ответов)└── Port/ # интерфейсы наружу: IPaymentGateway...
Правила игры:
-
core/не знает про Symfony, HTTP и Doctrine. Вообще. -
Контроллеры тонкие: валидируют вход, диспатчат Command/Query через порт шины, возвращают View. Вся логика — в хендлерах.
-
Зависимости между контекстами направленные:
Taskоркестрирует пайплайн, поэтому ему можно видетьBilling(списать кредиты),Media(взять файл) иTool(взять рецепт обработки).Monitoringвидит толькоTask. Листовые контексты (Auth,Billing,Media,Tool) не знают ни о ком, кромеShared.
Однотипность структуры — это отдельный подарок агенту: «новая фича = новый Command + Handler, вот три соседних примера» работает почти безотказно. LLM — машина продолжения паттернов; дайте ей чистый паттерн, и она будет его воспроизводить. Дайте ей зоопарк — и она будет воспроизводить зоопарк.
Проблема в одном: сами по себе эти правила — джентльменское соглашение. И LLM — не джентльмен.
Deptrac: архитектура, которую нельзя нарушить
Сначала я описал правила слоёв в CLAUDE.md. Агент их читал. И всё равно нарушал — не назло, а потому что в обучающей выборке инжектить репозиторий куда попало «нормально». Пара реальных примеров из моих диффов:
// core/Task/Domain/Entity/ProcessingTask.phpuse App\Infrastructure\Task\DoctrineTaskRepository; // инфраструктура в домене// core/Monitoring/Application/GetProviderHealthHandler.phpuse Core\Billing\Domain\Entity\CreditBalance; // чужой контекст напрямую
Работает? Работает. Тесты проходят? Проходят. PHPStan молчит? Молчит. А архитектура уже потекла, и следующие правки поедут по этой колее.
Вывод, к которому я пришёл: правила, которые не проверяются автоматически, для LLM не существуют. Нужен enforcement — на pre-commit и в CI. Для PHP это deptrac: статический анализатор, который строит граф зависимостей между классами и проверяет его против декларативных правил. Слои описываются коллекторами (по неймспейсу, регулярке, атрибуту), ruleset говорит, кому кого можно видеть. Любая зависимость вне правил — violation, ненулевой код выхода, красный CI.
Конфиг №1: вертикальные слои
Первый конфиг режет систему горизонтальными пластами — классическая луковица чистой архитектуры. Упрощённая версия:
# deptrac.yaml — вертикальное разделение слоёвdeptrac: paths: [./src/, ./core/] layers: - name: Api collectors: - { type: classNameRegex, value: '#^App\\Api\\#' } - name: Infrastructure collectors: - { type: classNameRegex, value: '#^App\\Infrastructure\\#' } - name: Domain collectors: - { type: classNameRegex, value: '#^Core\\.*\\Domain\\#' } - name: Application collectors: - type: bool must: [{ type: classNameRegex, value: '#^Core\\.*\\Application\\#' }] must_not: # контракты выносим в отдельный слой - { type: classNameRegex, value: '#(Command|Query|View|Job)$#' } - name: Contracts # CQRS-DTO и порты — публичная поверхность core collectors: - { type: classNameRegex, value: '#^Core\\.*\\Application\\.*(Command|Query|View|Job)$#' } - { type: classNameRegex, value: '#^Core\\.*\\Port\\#' } - { type: classNameRegex, value: '#^Core\\Port\\#' } - name: Shared collectors: - { type: classNameRegex, value: '#^Core\\Shared\\#' } - name: Symfony collectors: - { type: classNameRegex, value: '#^Symfony\\#' } - name: Doctrine collectors: - { type: classNameRegex, value: '#^Doctrine\\#' } ruleset: Api: [Contracts, Shared, Symfony] # контроллер видит только DTO и порты Infrastructure: [Contracts, Application, Domain, Shared, Symfony, Doctrine] Application: [Domain, Contracts, Shared] Contracts: [Domain, Shared] Domain: [Shared] # домен не знает ни о чём Shared: ~ Symfony: ~ Doctrine: ~
Обратите внимание на слой Contracts. Контроллеру из Api нельзя видеть ни домен, ни хендлеры — только команды, запросы, view-DTO и интерфейсы шин. Это заставляет держать контроллеры тонкими автоматически: захотел агент «по-быстрому» дёрнуть доменную сущность из контроллера — deptrac не пустит.
Вендорные слои (Symfony, Doctrine) объявлены явно — так намерение видно в ruleset: Domain не имеет их в списке, значит попытка заюзать Doctrine-аннотацию в доменной сущности — это violation, а не «ну, все так делают».
Конфиг №2: горизонтальная изоляция контекстов
Первый конфиг ничего не говорит о том, может ли Billing залезть в Media. Это второй, отдельный конфиг:
# deptrac-contexts.yaml — изоляция ограниченных контекстовdeptrac: paths: [./src/, ./core/] layers: - name: Api collectors: [{ type: classNameRegex, value: '#^App\\Api\\#' }] - name: Infrastructure collectors: [{ type: classNameRegex, value: '#^App\\Infrastructure\\#' }] - name: Auth collectors: [{ type: classNameRegex, value: '#^Core\\Auth\\#' }] - name: Billing collectors: [{ type: classNameRegex, value: '#^Core\\Billing\\#' }] - name: Media collectors: [{ type: classNameRegex, value: '#^Core\\Media\\#' }] - name: Task collectors: [{ type: classNameRegex, value: '#^Core\\Task\\#' }] - name: Tool collectors: [{ type: classNameRegex, value: '#^Core\\Tool\\#' }] - name: Monitoring collectors: [{ type: classNameRegex, value: '#^Core\\Monitoring\\#' }] - name: Shared collectors: - { type: classNameRegex, value: '#^Core\\Shared\\#' } - { type: classNameRegex, value: '#^Core\\Port\\#' } ruleset: # адаптеры обслуживают все контексты Api: [Auth, Billing, Media, Task, Tool, Monitoring, Shared] Infrastructure: [Auth, Billing, Media, Task, Tool, Monitoring, Shared] # листовые контексты не знают друг о друге Auth: [Shared] Billing: [Shared] Media: [Shared] Tool: [Shared] # Task оркестрирует пайплайн: кредиты, файлы, рецепты инструментов Task: [Billing, Media, Tool, Shared] # Monitoring смотрит на здоровье Task и не лезет никуда больше Monitoring: [Task, Shared] Shared: ~
Ruleset здесь — это, по сути, карта контекстов (context map) из DDD, записанная исполняемым кодом. Она читается как документация: видно, кто оркестратор, кто листья, и почему Monitoring не может дотянуться до Billing.
Почему два конфига, а не один
Первый порыв — засунуть всё в один файл: и слои, и контексты. Я попробовал, и это те грабли, ради которых стоило писать статью.
Deptrac позволяет классу принадлежать нескольким слоям и проверяет каждую пару «слой зависимого — слой зависимости». Класс Core\Billing\Domain\Entity\Invoice в объединённом конфиге принадлежит одновременно слою Domain и слою Billing. Его зависимость от Core\Billing\Domain\ValueObject\Money (тоже Domain + Billing) порождает четыре проверки: Domain→Domain, Domain→Billing, Billing→Domain, Billing→Billing. И если вертикальная матрица разрешает не всё то же самое, что горизонтальная, вы получаете ложные violations на абсолютно легальном коде. Начинаешь давить их разрешениями — и матрица правил превращается в решето, где уже непонятно, что вообще запрещено.
Решение: два независимых конфига с взаимоисключающими наборами слоёв. Один отвечает на вопрос «правильный ли это слой», второй — «правильный ли это контекст». Каждый файл маленький, читаемый, у каждого violation — однозначная причина. Бонус: правила эволюционируют с разной скоростью. Вертикаль застыла ещё в начале проекта, а вот контекстная карта живёт — новый контекст появляется, связь добавляется осознанным коммитом в deptrac-contexts.yaml, и этот коммит виден на ревью как архитектурное решение.
Запускается всё в pre-commit хуке и в CI:
vendor/bin/deptrac analyse --config-file=deptrac.yamlvendor/bin/deptrac analyse --config-file=deptrac-contexts.yaml
Теперь тот самый импорт чужого контекста заканчивается не молчаливой эрозией, а так:
----------- ------------------------------------------------------------ Reason Monitoring ----------- ------------------------------------------------------------ Violation Core\Monitoring\Application\GetProviderHealthHandler must not depend on Core\Billing\Domain\Entity\CreditBalance ----------- ------------------------------------------------------------
И — важный момент — агент читает эту ошибку и сам чинит код правильно: заводит порт, событие или запрос через шину. Ему не нужно объяснять, что не так: сообщение об ошибке само по себе обучающий сигнал, попавший точно в контекст в нужный момент. Это вообще главное свойство машиночитаемых правил: они превращают нарушение из «дефекта, который заметят через месяц» в «ошибку компиляции, которую агент исправит за одну итерацию».
CLAUDE.md: намерение против факта
Deptrac проверяет факт. Но агенту всё ещё нужно знать намерение — иначе он будет писать код методом «нагенерил → отхватил от линтера → переделал». Работает, но дорого и медленно.
Для намерений есть файл правил проекта (CLAUDE.md, AGENTS.md — у кого что). Что туда стоит класть, по опыту:
-
Карту репозитория — та схема каталогов, что выше, с одной строкой о назначении каждого куска.
-
Команды проверки. У меня это одна команда
make validate, которая гоняет cs-fixer, phpstan, оба deptrac-конфига и тесты. Агенту прописано запускать её после любых изменений бакенда. -
Архитектурные инварианты словами: «core/ не зависит от Symfony и Doctrine», «новая фича = новый Command/Query + Handler», «контроллер: валидация → диспатч → View, ничего больше».
-
Запрещённые приёмы, на которых агент попадался: «никогда не редактируй файлы миграций руками — только генерация», «никакого PDO/SQL напрямую в обход репозиториев», «не добавляй абстракцию, пока нет второй реализации» (это против YAGNI-графомании).
Формулировки — короткие и императивные, в идеале с парой «плохо/хорошо». LLM следует правилам тем лучше, чем меньше они похожи на эссе.
Принцип, который я вывел для себя:
Правила описывают намерение. Deptrac проверяет факт. Одно без другого не работает.
Правила без enforcement агент нарушает. Enforcement без правил превращает разработку в игру «угадай, что запрещено, по сообщениям об ошибках». Вместе они дают агенту то же, что хорошему новому сотруднику: понятный онбординг плюс CI, который не пустит халтуру.
Субагент-ревьювер: второй взгляд до PR
Остался последний слой защиты — от того, что статика не ловит в принципе: соответствие реализации задаче, лишние абстракции, дубли логики, пропущенные edge cases.
Сначала я просто просил того же агента «проверь свой код». Это почти бесполезно: агент, только что написавший код, смотрит на него теми же глазами, тем же контекстом и с той же уверенностью, что всё правильно. Он «заражён» собственными решениями — как и человек, перечитывающий свой код через минуту после написания.
Работает другое: отдельный субагент-ревьювер с чистым контекстом. Он не видел рассуждений кодера, не привязан к его решениям и читает диф так, как читал бы чужой PR. В Claude Code это отдельный агент в .claude/agents/, а весь процесс оформлен скилом — тем самым «пайплайном разработки».
Скил: пайплайн разработки
---name: development-pipelinedescription: Используй для нетривиальных задач разработки: новые фичи, изменения домена/API/схемы БД, многофайловые правки, рискованные рефакторинги. Цикл план → developer → reviewer → фиксы до принятия.---# Пайплайн разработкиОбязательный процесс для нетривиальных задач:1. Главный агент составляет конкретный план.2. Реализацию делает субагент `developer`.3. Диф проверяет субагент `reviewer` (чистый контекст, диф + план, без истории рассуждений разработчика).4. Блокирующие замечания → сфокусированный fix-план → снова `developer`.5. Цикл повторяется, пока `reviewer` не примет диф.6. Главный агент запускает `make validate` и отчитывается пользователю.## Требования к плану для developer- цель и границы (что в скоупе, что нет);- файлы и документы, которые нужно посмотреть;- шаги реализации;- ограничения (слои, контексты, запрещённые приёмы);- обязательные проверки (`make validate`);- формат ответа: изменённые файлы + результаты проверок.## Правило приёмкиЗадача не считается выполненной, пока у reviewer остаются блокирующиезамечания или `make validate` красный. Замечания уровня «вкусовщина»reviewer помечает как non-blocking — они не крутят цикл.
Субагент-разработчик
---name: developerdescription: Реализует переданный план. Не принимает решений о скоупе.model: sonnet---Ты — разработчик. Тебе дают готовый план — выполни его точно.- Не выходи за скоуп плана. Заметил проблему рядом — упомяни в отчёте, но не чини.- Следуй паттернам соседнего кода: новая фича = Command/Query + Handler по образцу существующих.- После изменений запусти `make validate` и приложи результат.- Верни: список изменённых файлов, что сделано, результаты проверок.
Субагент-ревьювер
---name: reviewerdescription: Ревьюит диф после developer. Чистый контекст, только диф и план.model: opustools: Read, Grep, Glob, Bash---Ты — архитектурный ревьювер. Тебе дают план задачи и диф. Кода ты неписал, решений разработчика не видел — оценивай только результат.Проверь по порядку:1. **Соответствие плану.** Всё ли из скоупа сделано? Нет ли изменений вне скоупа?2. **Архитектура.** Слои и контексты (сверх deptrac — семантика: доменная логика не утекла в контроллер или инфраструктуру?).3. **DRY.** Поищи по кодовой базе: нет ли уже существующей реализации того, что написано заново?4. **YAGNI.** Есть ли абстракции без второй реализации, параметры, которые никто не передаёт, «задел на будущее»?5. **Корректность.** Edge cases, обработка ошибок, конкурентность, безопасность (авторизация, инъекции, доступ к чужим данным).6. **Тесты.** Покрывают ли новое поведение? Не подогнаны ли под багованную реализацию вместо ожидаемого поведения?Формат ответа: список замечаний, каждое с пометкой BLOCKING илиNON-BLOCKING, с файлом, строкой и предложением, как исправить.Если блокирующих нет — напиши APPROVED первой строкой.
Пара наблюдений после нескольких месяцев работы этой схемы.
Разделение окупается экономически. Кодеру уже не нужно быть самой умной моделью — план составлен, паттерны в кодовой базе чистые, за спиной линтеры. Реализацию тянет модель подешевле. А вот ревьювер и планировщик — та точка, где ум стоит своих денег: одна пойманная до PR проблема экономит целый цикл «человек посмотрел → написал замечание → агент переделал».
Чистый контекст ревьювера — не деталь, а суть. Пункты 3 и 4 его чек-листа (DRY и YAGNI) — это ровно те болезни LLM, о которых была первая половина статьи. Ловить их может только тот, кто не участвовал в написании: кодер всегда считает свою абстракцию нужной, а свою копипасту — уникальным случаем.
Человек смещается в конец конвейера. Итоговый пайплайн: план → генерация → pre-commit (cs-fixer, phpstan, deptrac ×2) → субагент-ревью → и только потом мои глаза. До меня доезжают споры и продуктовые решения, а не «тут забыли проверить null» и не «зачем здесь фабрика».
Выводы
Если сжать полгода набивания шишек в несколько строк:
-
LLM — усилитель, а не замена инженерной культуры. Хорошую архитектуру он масштабирует, плохую превращает в болото быстрее, чем любая команда людей.
-
Качество кода теперь измеряется в токенах. Чем меньше контекста нужно для изменения, тем лучше архитектура. SRP, низкий coupling, ограниченные контексты — это буквально оптимизация под контекстное окно.
-
Правила, которые не проверяются автоматически, для LLM не существуют. Намерение — в правилах проекта, факт — в deptrac/phpstan/тестах. Сообщение об ошибке — лучший способ доставить знание в контекст агента точно в момент, когда оно нужно.
-
DDD — на бакенд, фронту — feature-sliced. Не тащите слои туда, где нет инвариантов.
-
Ревьювер должен быть отдельным агентом с чистым контекстом. Self-review — это плацебо.
Чек-лист внедрения на существующем PHP-проекте:
-
[ ] Зафиксировать целевые слои и контексты (хотя бы на салфетке — это будущий ruleset).
-
[ ] Поставить deptrac, описать вертикальные слои; текущие нарушения — в baseline, новые — в запрет.
-
[ ] Вторым конфигом описать карту контекстов. Два файла, не один.
-
[ ] Собрать одну команду
make validate(стиль + статика + deptrac + тесты) и включить её в pre-commit и CI. -
[ ] Написать правила проекта: карта репо, инварианты, запрещённые приёмы, команда проверки.
-
[ ] Завести субагентов developer и reviewer и оформить пайплайн скилом.
А дальше — тот же вечный процесс, что и с людьми: правила уточняются после каждого пойманного нарушения. Просто итерации теперь занимают минуты.
ссылка на оригинал статьи https://habr.com/ru/articles/1058064/