Полгода назад я начал писать пет-проект практически полностью с помощью 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 самое ценное: карту.
Ограниченные контексты и единый язык — это, по сути, способ разбить систему на куски, каждый из которых можно понять изолированно. Для агента неймспейс CoreBillingDomainEntityCreditBalance — это уже документация: понятно, что это, где живёт и кого касается, ещё до чтения кода. 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.php
use AppInfrastructureTaskDoctrineTaskRepository; // инфраструктура в домене
// core/Monitoring/Application/GetProviderHealthHandler.php
use CoreBillingDomainEntityCreditBalance; // чужой контекст напрямую
Работает? Работает. Тесты проходят? Проходят. 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 позволяет классу принадлежать нескольким слоям и проверяет каждую пару «слой зависимого — слой зависимости». Класс CoreBillingDomainEntityInvoice в объединённом конфиге принадлежит одновременно слою Domain и слою Billing. Его зависимость от CoreBillingDomainValueObjectMoney (тоже 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.yaml
vendor/bin/deptrac analyse --config-file=deptrac-contexts.yaml
Теперь тот самый импорт чужого контекста заканчивается не молчаливой эрозией, а так:
----------- ------------------------------------------------------------
Reason Monitoring
----------- ------------------------------------------------------------
Violation CoreMonitoringApplicationGetProviderHealthHandler must
not depend on CoreBillingDomainEntityCreditBalance
----------- ------------------------------------------------------------
И — важный момент — агент читает эту ошибку и сам чинит код правильно: заводит порт, событие или запрос через шину. Ему не нужно объяснять, что не так: сообщение об ошибке само по себе обучающий сигнал, попавший точно в контекст в нужный момент. Это вообще главное свойство машиночитаемых правил: они превращают нарушение из «дефекта, который заметят через месяц» в «ошибку компиляции, которую агент исправит за одну итерацию».
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-pipeline
description: Используй для нетривиальных задач разработки: новые фичи,
изменения домена/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: developer
description: Реализует переданный план. Не принимает решений о скоупе.
model: sonnet
---
Ты — разработчик. Тебе дают готовый план — выполни его точно.
- Не выходи за скоуп плана. Заметил проблему рядом — упомяни в отчёте,
но не чини.
- Следуй паттернам соседнего кода: новая фича = Command/Query + Handler
по образцу существующих.
- После изменений запусти `make validate` и приложи результат.
- Верни: список изменённых файлов, что сделано, результаты проверок.
Субагент-ревьювер
---
name: reviewer
description: Ревьюит диф после developer. Чистый контекст, только диф и план.
model: opus
tools: 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 и оформить пайплайн скилом.
А дальше — тот же вечный процесс, что и с людьми: правила уточняются после каждого пойманного нарушения. Просто итерации теперь занимают минуты.
Автор: kotafey


