В прошлой статье я рассказывал, как полгода писал пет-проект руками LLM-агентов, как он за три месяца проехал путь «от стартапа до болота» и что с этим сделал бэкенд: чистая архитектура, два конфига deptrac и субагент-ревьювер. Главный тезис был такой: архитектура — это не эстетика, а способ минимизировать объём контекста, необходимый для одного изменения.
Фронтенду в той статье досталась одна секция, и та отрицательная: «DDD туда не тащите». В комментариях резонно спросили: хорошо, не тащить — а что тащить? Эта статья — ответ. Тот же проект, тот же принцип, но другая половина стека: Next.js вместо Symfony, feature-sliced вместо ограниченных контекстов, dependency-cruiser вместо deptrac. И одно неожиданное открытие про Tailwind.
Долг из первой статьи
Коротко для тех, кто не читал первую часть (лучше прочитать — эта опирается на неё). Проект — сервис AI-обработки изображений: пользователь загружает фото, выбирает инструмент, платит кредитами, получает результат. Бэкенд — модульный монолит на Symfony, фронтенд — Next.js App Router, TypeScript, TanStack Query, Tailwind. Пишется всё практически полностью LLM-агентами.
Выводы первой статьи, которые понадобятся здесь:
LLM деградирует на плохой архитектуре так же, как команда людей, только быстрее: высокий coupling означает, что для безопасной правки нужно загрузить в контекст больше, чем туда влезает.
Правила, которые не проверяются автоматически, для LLM не существуют. Намерение — в правилах проекта, факт — в статическом анализе.
Сообщение об ошибке — лучший способ доставить знание в контекст агента ровно в тот момент, когда оно нужно.
Всё это стек-агностично. А вот инструменты — нет. Поехали.
Почему фронт деградирует ещё быстрее бэкенда
Первый месяц фронтовый vibe coding шёл даже бодрее бэкендового: экраны собирались на глазах, вёрстка — это то, что LLM делает эффектнее всего. Похмелье тоже наступило раньше. К концу второго месяца у меня было:
Пять кнопок. Не пять использований одной кнопки — пять компонентов
Button,PrimaryButton,ActionButton,SubmitBtnи просто<button className="...">с одинаковым набором классов, скопированным в шестнадцать мест. Плюс три модалки и четыре спиннера.Компонент страницы инструмента на 700 строк, в котором жили: загрузка файла, опрос статуса задачи, локальная копия баланса кредитов, логика цен и разметка. Любая правка любой из этих вещей означала «загрузи в контекст все 700 строк и молись».
useEffect-спагетти. Состояние синхронизировалось с другим состоянием через эффекты, эффекты триггерили друг друга, и агент чинил бесконечные ре-рендеры добавлением ещё одного эффекта с флагом.
Рассинхрон данных. Сервер сказал «кредитов 40», шапка показывает 40, а модалка покупки — 50, потому что где-то по дороге серверный ответ скопировали в
useStateи забыли обновить.
Почему на фронте это происходит быстрее? У меня два объяснения.
Первое — обучающая выборка. Если бэкендный говнокод на GitHub разбавлен хотя бы энтерпрайзом с ревью, то фронтовая выборка — это ещё и пятнадцать лет туториалов, jQuery-эры и копипасты со StackOverflow. Хуже того: в ней вперемешку лежат три несовместимых поколения React — классовые компоненты, эра «фетчим в useEffect» и современные серверные компоненты. Модель не знает, какой сейчас год в вашем проекте. Без жёсткого образца она тянет паттерны из всех трёх эпох сразу, иногда в одном файле.
Второе — сама природа JSX. На бэкенде, чтобы смешать слои, нужно хотя бы сделать импорт, и deptrac его увидит. На фронте разметка, логика, стили и состояние легально живут в одном файле. Это сила компонентной модели — и открытая дверь для god-компонентов: агенту всегда «дешевле» дописать ещё один useState и ещё один блок JSX в существующий файл, чем выделить новый. God-класс на бэкенде растёт месяцами — god-компонент вырастает за неделю.
Тот же тезис, новая проекция
Применим оптику первой статьи. Возьмём типовую задачу: «поменяй поведение кнопки покупки кредитов — добавь состояние загрузки и обработку отказа платежа».
В плохом фронте контекст этой правки: компонент страницы на 700 строк + глобальный стор, где лежит копия баланса + CSS-файл, если стили отдельно + три места, где кнопка используется + где-то ещё логика опроса статуса платежа. Это 20+ тысяч токенов только на «понять, что трогаю». Агент прочитает половину и начнёт править на ощупь — со всеми последствиями из первой статьи.
В хорошем фронте контекст правки — одна папка:
features/buy-credits/ ├── PurchaseButton.tsx ├── PurchaseDialog.tsx ├── model.ts └── index.ts
Всё, что меняется вместе, лежит рядом. Это принцип колокации, и для LLM он даже важнее, чем для человека: человек держит в голове проект между задачами, у агента каждый диалог начинается с нуля. Папка слайса — это готовая «порция контекста»: прочитал одну директорию — можешь безопасно править.
Собственно, это и есть фронтовый аналог ограниченного контекста из DDD. Только граница проходит не по бизнес-инвариантам (их на фронте нет — они на бэкенде), а по пользовательским сценариям: «купить кредиты», «загрузить фото», «выбрать инструмент». В комментариях к первой статье это сформулировали точнее меня: на фронте не приживается тактическая часть DDD — агрегаты, репозитории, доменные сервисы. А вот стратегическая — единый язык и границы контекстов — работает и там. Слайсы и есть её фронтовое воплощение: имена фич говорят на языке продукта, границы проходят по сценариям.
Feature-Sliced Design, урезанный до трёх слоёв
Готовая методология с такими границами давно существует — Feature-Sliced Design. Если совсем коротко: код режется на горизонтальные слои с фиксированным направлением зависимостей, а внутри слоёв — на вертикальные слайсы по смыслу, и слайсы одного слоя друг о друге не знают.
Канонический FSD предлагает до шести слоёв: app, pages, widgets, features, entities, shared. Я честно начал раскладывать по канону — и быстро поймал себя на том самом грехе, за который ругал LLM в первой статье: слои ради слоёв. У каждого «виджета» оказывался ровно один потребитель, у каждой «сущности» — ровно одна фича. Для продуктового SPA моего размера рабочими оказались три слоя:
apps/frontend/ ├── app/ # маршруты Next.js: тонкая композиция фич — как контроллеры └── src/ ├── features/ # пользовательские сценарии: upload-photo, buy-credits, run-tool └── shared/ # фундамент: api-клиент, ui-kit, lib, i18n — не знает о домене
Правила игры — считайте, ruleset из первой статьи:
Импорты только вниз:
app→features→shared. Никогда вверх и никогда вбок.Слайсы изолированы:
features/buy-creditsне может импортировать изfeatures/upload-photo. Нужно общее — оно спускается вshared.У каждого слайса есть публичный API —
index.ts. Снаружи можно импортировать только его: внутренности слайса — приватная зона.
Внутри слайса никакой обязательной структуры нет: компоненты, model.ts с логикой и types.ts лежат плоско, пока фича обозрима одним взглядом; сегменты ui/ и model/ появляются, когда перестаёт. Слои entities и widgets из канона я держу в уме как направление роста: понадобится одна сущность в трёх фичах — появится entities; растолстеет композиция в app/ — появятся widgets. Но заводить их заранее — ровно та «преждевременная гибкость», которую мы же у LLM и лечим. Методология — словарь, а не устав караульной службы.
Что это даёт агенту — ровно то же, что неймспейсы Core\Billing\Domain\... давали на бэкенде: карту, читаемую без чтения кода. Путь features/buy-credits/model.ts — это уже документация: сценарий покупки кредитов, логика, а не разметка. Задача «добавь фичу отмены задачи» превращается в «создай features/cancel-task по образцу соседних» — а LLM, как мы помним из первой статьи, машина продолжения паттернов. Дайте ей десять однотипных слайсов, и одиннадцатый она сделает правильно почти без инструкций.
И второе, не менее важное: слайсы — это готовые границы для правок. Изменение фичи не выходит за пределы её папки, а всё, что способно задеть соседей, обязано жить в shared — и круг пострадавших виден по импортам его публичного API, а не по «поиску по всему проекту».
Домен фронта живёт на бэкенде
В первой статье я говорил, что фронту вместо доменной модели нужен типизированный API-клиент, сгенерированный из контрактов бэкенда. Теперь покажу, почему это оказался самый рентабельный инструмент во всём фронтовом стеке.
Точка правды — OpenAPI-спека, которую бэкенд генерирует из своих контроллеров. Из неё orval генерирует в shared/api/generated типы и готовые хуки TanStack Query:
// orval.config.ts export default { flemo: { input: '../backend/var/openapi.json', output: { target: 'src/shared/api/generated', client: 'react-query', mode: 'tags-split', }, }, };
// features/buy-credits/PurchaseDialog.tsx const { data: plans } = useGetCreditPlans(); // сгенерировано const { mutate: purchase } = usePurchaseCredits(); // сгенерировано
Что это даёт в LLM-разработке:
Агент не может выдумать поле. Без генерации агент пишет task.status === 'completed', а на сервере статус называется done — и это обнаруживается в рантайме, в лучшем случае на ревью. Со сгенерированными типами это ошибка компиляции через секунду после написания.
Изменение контракта распространяется само. Бэкенд переименовал поле → перегенерили клиент → tsc красный ровно в тех местах фронта, которые отстали. Дальше происходит то же, что с deptrac в первой статье: агент читает ошибки компиляции и чинит каждую — ему не нужно объяснять, что изменилось, список ошибок и есть задача. Ошибка компиляции — это обучающий сигнал, доставленный точно в контекст в нужный момент.
Исчезает целый класс ручного кода. Никаких рукописных fetch-обёрток, типов ответов и ключей кэша — то есть никаких мест, где агент мог бы их продублировать чуть-чуть по-разному.
С генерацией связано и главное правило состояния — водораздел, который я в итоге прописал жирным шрифтом:
Серверными данными владеет TanStack Query. В сторе и useState — только UI-состояние.
Баланс кредитов, список задач, статусы — это кэш серверных данных, и живёт он в query-кэше с инвалидацией. Открыта ли модалка, какой шаг визарда активен, что введено в форму — это UI-состояние, ему место в useState или маленьком zustand-сторе. Тот рассинхрон «в шапке 40, в модалке 50» был ровно нарушением этого водораздела: агент скопировал данные запроса в стор, потому что в его обучающей выборке так делают миллион раз. Запрещённый приём номер один во фронтовой секции CLAUDE.md: useState(data) от результата запроса.
Enforcement: deptrac для фронта
Всё перечисленное — слои, изоляция слайсов, публичные API, запрет на ручной fetch — пока что джентльменское соглашение. А LLM, как мы выяснили в первой статье, не джентльмен: правила из CLAUDE.md он читает и всё равно нарушает, потому что «в обучающей выборке так нормально».
На бэкенде факт проверял deptrac. На фронте ту же роль играет его ближайший родственник — dependency-cruiser: тот же принцип «построить граф импортов и проверить против декларативных правил», только для JS/TS. Архитектурная часть моего конфига:
// .dependency-cruiser.cjs — архитектурные правила (сокращено) module.exports = { forbidden: [ { name: "feature-public-api", severity: "error", comment: "app/ импортирует фичу только через её index.ts. " + "Внутренности слайса — приватная зона.", from: { path: "^app/" }, to: { path: "^src/features/", pathNot: "index\\.ts$" }, }, { name: "no-shared-to-features-app", severity: "error", comment: "shared/ — нижний слой: ему нельзя знать о фичах и app.", from: { path: "^src/shared/" }, to: { path: "^(src/features/|app/)" }, }, { name: "no-features-to-app", severity: "error", comment: "Фичи не тянутся вверх: только features → shared.", from: { path: "^src/features/" }, to: { path: "^app/" }, }, { name: "no-cross-feature", severity: "error", comment: "Фича не импортирует другую фичу. Общее — вниз, в shared/.", from: { path: "(^src/features/)([^/]+)/" }, to: { path: "^$1", pathNot: "$1$2/" }, }, { name: "no-circular", severity: "error", comment: "Циклическая зависимость. Разорвать: инверсия или вынос в shared/.", from: {}, to: { circular: true }, }, ], options: { tsConfig: { fileName: "tsconfig.json" }, // алиасы @/* резолвятся сами tsPreCompilationDeps: true, // видеть и type-only импорты }, };
Разберём, что здесь зашито.
Вертикаль и горизонталь — в одном файле. Правила no-shared-to-features-app и no-features-to-app держат направление слоёв, no-cross-feature — изоляцию слайсов, feature-public-api — приватность внутренностей. Помните грабли из первой статьи — почему deptrac потребовал два взаимоисключающих конфига? Там класс принадлежал двум слоям сразу, и каждая зависимость порождала перекрёстные ложные проверки. Здесь этой проблемы нет по построению: правило dependency-cruiser — это просто пара «регексп from → регексп to», модуль не обязан «состоять в слоях». Один конфиг вместо двух — редкий случай, когда фронту досталось проще.
Изоляция слайсов — один регексп, а не матрица. Посмотрите на no-cross-feature: from захватывает имя слайса capture-группой, to запрещает лезть в features/, кроме собственной папки ($1$2/). Не нужно перечислять пары фич — новый слайс попадает под защиту автоматически, конфиг не растёт вместе с проектом. На бэкенде каждая связь контекстов добавлялась в ruleset осознанным коммитом; на фронте связей между слайсами не бывает вообще, поэтому и перечислять нечего.
no-circular — то, чего у deptrac-конфигов из первой статьи не было. На фронте циклы импортов — бытовая травма (два компонента импортируют друг друга через барреллы), и LLM создаёт их не задумываясь.
Нарушение выглядит так (репортёр err-long печатает comment правила прямо под ошибкой):
error no-cross-feature: src/features/buy-credits/PurchaseDialog.tsx → src/features/upload-photo/model.ts Фича не импортирует другую фичу. Общее — вниз, в shared/. ✖ 1 dependency violations (1 errors, 0 warnings)
И дальше — тот же эффект, что с deptrac: агент читает ошибку и сам перестраивает код — спускает общее в shared или поднимает композицию в app/. Поэтому comment в правилах стоит писать не для себя, а для агента: это микро-CLAUDE.md, который доставляется в контекст точно в момент нарушения. Лучшей системы доставки знаний в голову LLM пока не придумали.
Бонусом dependency-cruiser даёт «санитарные» правила, которые в LLM-разработке оказались не менее ценными, чем архитектурные:
no-orphans— модуль, который никто не импортирует. После LLM-рефакторингов такие остаются пачками: «запасной» компонент, забытая утилита.not-to-dev-dep— прод-код тянет пакет изdevDependencies: агент поставил зависимость не в ту секцию, и это выяснилось бы только при сборке в проде.not-to-spec— прод-код импортирует из теста. Да, агент так делает: нашёл в тесте удобный хелпер — и заимпортил.
Альтернативы, чтобы выбирать осознанно: eslint-plugin-boundaries делает похожее внутри ESLint (плюс подсветка в IDE на лету), steiger — официальный линтер FSD, знает про слои и публичные API из коробки. Я выбрал dependency-cruiser за то, что он ближе всех к deptrac по духу и умеет то, чего ESLint-плагины не умеют: циклы, orphans, контроль секций package.json. Запускается одной командой (pnpm arch:check) в pre-commit и CI.
К границам добавляются ещё три уровня enforcement:
TypeScript на максимальной строгости. strict: true — это минимум. Дальше noUncheckedIndexedAccess, exactOptionalPropertyTypes и запрет any через @typescript-eslint/no-explicit-any. Причина не академическая: каждый any — это дыра, через которую агент протащит что угодно, и типовая система перестанет быть тем самым «обучающим сигналом». LLM обожает as any как способ «починить» ошибку компиляции — это должно быть жёстко запрещено и линтером, и правилами.
knip против мёртвого кода. Точки входа для knip — маршруты app/ и публичные API слайсов (src/**/index.ts): всё, что недостижимо из них, — мёртвый код. Красивая симметрия: тот же index.ts, который для dependency-cruiser — граница приватности, для knip — граница живости. Человек копит мёртвые экспорты годами, агент — неделями; каждый удалённый файл — минус кусок контекста, который агент больше никогда не прочитает зря.
Свои проверки дописываются скриптами. Не на всё есть готовый линтер. Пример: LLM патологически хардкодит тексты в разметку мимо i18n — «Save» и «Cancel» прямо в JSX, хотя в правилах написано «только через словарь». Лечится самописным CI-guard’ом: скрипт сканирует прод-код на строковые UI-литералы и сравнивает с закоммиченным baseline-инвентарём; любой новый литерал — красный CI. Легализовать строку можно только явным обновлением baseline в том же PR — и на ревью это видно как осознанное решение, а не проскользнувший хардкод. Это принцип «каждое пойманное нарушение → правило или проверка» из первой статьи, доведённый до конца: если нужной проверки не существует, её пишет тот же агент за полчаса.
Стили: почему Tailwind внезапно LLM-friendly
Признание: до этого проекта я Tailwind не любил. «Грязная разметка, стили размазаны по классам, CSS уже придумали». Проект с LLM-агентами заставил пересмотреть позицию, и вот почему.
Вспомним тезис про минимизацию контекста. Отдельный CSS-файл — это второй файл, который нужно затащить в контекст для любой правки внешнего вида, и который нужно не забыть обновить. Агент забывает: правит разметку, не правит стили, или наоборот — классический рассинхрон в двух файлах. С Tailwind стиль живёт в той же строке, что и разметка: правка локальна, контекст — один файл, рассинхрону физически негде возникнуть.
Второй аргумент — ограниченный словарь. В свободном CSS агент изобретает margin: 13px, color: #4a90d9 и z-index: 9999 в каждом новом компоненте — чуть-чуть по-разному. Tailwind принуждает к дискретной шкале токенов: p-4, text-primary, gap-2. Дизайн-токены в конфиге — это, по сути, ubiquitous language фронтенда: конечный набор слов, которыми описывается внешний вид, и модель с конечными словарями работает отлично. Плюс банальное: обучающая выборка по Tailwind огромна и однородна.
Дисклеймер для комментариев: это не аргумент в holy war. Колокейтед CSS Modules с токенами через custom properties дают похожий эффект. Пуанта не «Tailwind лучше», а «стили должны жить рядом с разметкой и говорить на конечном словаре» — Tailwind просто даёт это из коробки.
Но токены не решают проблему пятой кнопки — ту, с которой началась статья. Её решает связка:
shared/ui— единственный источник базовых компонентов. Кнопки, инпуты, модалки, спиннеры живут только там.Правило в CLAUDE.md: «прежде чем создать компонент, проверь
shared/uiи соседние слайсы; новый базовый компонент — только с явного согласия».Пункт в чек-листе ревьювера: поиск дублей — потому что первые два пункта агент всё равно иногда проигнорирует.
Это фронтовая версия войны с DRY-болезнью из первой статьи: LLM всегда «дешевле» сгенерировать кнопку заново, чем найти существующую. Генерация — его естественный режим, поиск — дорогая операция. Значит, поиск надо сделать дешёвым (маленький, обозримый shared/ui с говорящими именами) и обязательным (правило + ревью).
CLAUDE.md: фронтовая секция
Структура та же, что на бэкенде — карта, команды, инварианты, запрещённые приёмы. Приведу только специфичное для фронта, без повторения первой статьи:
## Frontend Карта: app/ (маршруты, тонкая композиция) → src/features/ (слайсы сценариев) → src/shared/ (api, ui, lib, i18n). Импорты только вниз, слайсы изолированы, снаружи слайса — только index.ts. После любых изменений фронта: `make fe-validate` (tsc --noEmit + eslint + depcruise + knip + tests). Инварианты: - Серверные данные — только через хуки из shared/api/generated. Руками fetch/axios не писать. Каталог generated/ не редактировать — только перегенерация (`make fe-api`). - Серверными данными владеет TanStack Query. В stores/useState — только UI-состояние. Никогда: useState(data) от результата запроса. - Новая фича = новый слайс в features/ по образцу соседних. - Прежде чем создать компонент — проверь shared/ui и соседние слайсы. Новый базовый компонент — только с явного согласия. - Тексты в UI — только через i18n-ключи, никаких строковых литералов в разметке. Запрещено: - useEffect для синхронизации состояния с состоянием. Нужное значение — деривация при рендере или selector. useEffect — только для внешних систем (подписки, DOM, таймеры). - `as any`, `@ts-ignore`, исключения в .dependency-cruiser.cjs — никогда. Ошибка границы означает, что код лежит не в том слое. - "use client" по умолчанию. Клиентским компонент делает только интерактив: обработчики, хуки состояния, браузерные API.
Про useEffect — отдельная боль, поэтому и отдельное правило. Синхронизация состояния эффектами — самый частый фронтовый анти-паттерн LLM: он тянется из эпохи, когда так писали все, и приводит к каскадам ре-рендеров, которые агент потом «чинит» флагами и таймаутами. Формулировка «useEffect — только для внешних систем» отсекает 90% таких случаев, а ревьювер добирает остальное.
Разомкнутый цикл: агент верстает вслепую
Теперь о главном отличии фронта, про которое молчат все разговоры об архитектуре.
На бэкенде цикл обратной связи замкнут: make validate зелёный — значит, поведение корректно с точностью до покрытия тестами. Агент может работать автономно часами, опираясь только на текстовый вывод инструментов.
На фронте типы, линтер и даже юнит-тесты не скажут главного: как это выглядит. Кнопка может уехать за экран, модалка — открыться под шапкой, тёмная тема — превратить текст в «чёрное на чёрном», и весь наш enforcement останется зелёным. Агент, который не видит экран, верстает вслепую — уверенно и с хорошим стилем кода.
Что с этим делаю я, по нарастающей стоимости:
Playwright e2e на критические сценарии (загрузка фото → обработка → результат; покупка кредитов). Ловят «сломалось совсем»: кнопка не кликается, форма не отправляется. Это всё ещё текстовый сигнал, агент отрабатывает его сам.
Скриншоты в тех же e2e. После прогона агент читает их мультимодально и сравнивает с ожиданием из плана. Ловит грубую вёрстку: наехало, уехало, пропало.
Браузер в руках агента (Playwright MCP): для интерактивных задач агент сам открывает страницу, кликает и смотрит на результат до и после правки.
Скажу честно: это самое слабое место конвейера. Скриншотное ревью дорогое, медленное и пропускает нюансы, которые человек видит за секунду. Долю ручной проверки на фронте мне пока сократить до бэкендового уровня не удалось — визуальный вкус остаётся за человеком. Но разница между «агент вообще не видит результат» и «агент видит хотя бы скриншот» — это разница между вёрсткой вслепую и вёрсткой в очках с сильно неправильными диоптриями. Второе заметно лучше.
Пайплайн: диф к первой статье
Сам конвейер не изменился ни на шаг: план → субагент developer → субагент reviewer с чистым контекстом → фиксы до принятия → make fe-validate → человек. Кто не читал, как это устроено и почему ревьювером должен быть отдельный агент, — это вторая половина первой статьи, повторяться не буду.
Единственное отличие — фронтовые пункты в чек-листе ревьювера:
Дополнительно для фронтенда: 7. **Дубли компонентов.** Поищи в shared/ui и соседних слайсах: нет ли уже такой кнопки/модалки/спиннера/хелпера? 8. **Состояние.** Серверные данные не скопированы в useState/store? Нет useEffect-синхронизации состояния с состоянием? 9. **Границы клиент/сервер.** "use client" только там, где есть интерактив? Секреты и тяжёлые зависимости не утекли в клиент? 10. **Доступность.** Интерактив — на элементах с ролями (button, a), фокус управляем, у иконок-кнопок есть aria-label? 11. **Скриншоты e2e.** Посмотри артефакты прогона: вёрстка соответствует плану? Ничего не наехало в обеих темах?
Пункт 7 — тот же DRY-пункт, что на бэкенде, просто с фронтовым адресом поиска. А вот пункты 9–11 бэкендового аналога не имеют: границы «use client», доступность и визуальная проверка — это чисто фронтовая поверхность ошибок, и статика её не покрывает.
Выводы
Сожму фронтовую часть опыта в несколько строк — нумерация продолжает первую статью по духу:
Тезис «архитектура = минимизация контекста» пережил смену стека без правок. Поменялись только инструменты: слайс вместо ограниченного контекста, dependency-cruiser вместо deptrac, index.ts вместо портов.
Фронту не нужен DDD — нужна колокация и карта. Feature-Sliced Design даёт агенту то же, что неймспейсы DDD на бэкенде: путь к файлу читается как документация, папка слайса — готовая порция контекста для правки. И слоёв нужно меньше, чем кажется: мне хватило трёх — заводить остальные «на всякий случай» значит болеть той же YAGNI-болезнью, которую лечим у LLM.
Домен фронта живёт на бэкенде — и должен приезжать оттуда кодогенерацией. Сгенерированный из OpenAPI клиент превращает каждое расхождение с контрактом в ошибку компиляции, а ошибка компиляции — в задачу, которую агент решает сам. Водораздел «server state в TanStack Query, UI state в сторе» закрывает целый класс багов рассинхрона.
Правила без enforcement по-прежнему не существуют. Слои, изоляция слайсов и публичные API — в dependency-cruiser (вся матрица границ — в одном конфиге: грабли «двух конфигов deptrac» здесь не растут по построению), контракт — в tsc на максимальной строгости, мёртвый код — в knip, а чего не хватает — дописывается скриптом с baseline, как guard против хардкода строк мимо i18n. И пишите
commentв правилах для агента: это знание, которое доставляется в его контекст точно в момент нарушения.Стили должны жить рядом с разметкой и говорить на конечном словаре токенов. Tailwind даёт это из коробки, поэтому оказался LLM-friendly вопреки моим предубеждениям. Пятую кнопку лечит маленький shared/ui плюс ревью, а не токены сами по себе.
Фронтовый цикл обратной связи разомкнут — замыкайте, чем можете. e2e, скриншоты, браузер в руках агента. Полностью замкнуть пока не получается; это честная цена фронтенда.
Чек-лист внедрения на существующем фронте:
[ ] Разметить слои — мне хватило трёх: app / features / shared; entities и widgets добавляйте, когда заболит. Правило: импорты вниз, слайсы изолированы, вход в слайс через index.ts.
[ ] Закодировать границы в dependency-cruiser (или eslint-plugin-boundaries / steiger); текущие нарушения — точечными исключениями, новые — в запрет. В comment каждого правила — объяснение для агента.
[ ] Поднять генерацию API-клиента из OpenAPI-спеки бэкенда; запретить рукописный fetch и правки в generated/.
[ ] Провести водораздел состояния: серверные данные → TanStack Query, UI-состояние → useState/стор. Найти и снести существующие копии серверных данных.
[ ] Выкрутить строгость: tsc strict+, запрет any, knip в CI (точки входа — маршруты и index.ts слайсов).
[ ] Собрать базовые компоненты в shared/ui, прописать правило «сначала ищи, потом создавай».
[ ] Одна команда
make fe-validate— в pre-commit и CI.[ ] Фронтовая секция в CLAUDE.md: карта, инварианты, запрещённые приёмы (useEffect-синхронизация, as any, “use client” по умолчанию).
[ ] e2e со скриншотами на критические сценарии — чтобы агент видел, что наверстал.
А дальше — то же, что и на бэкенде: каждое пойманное нарушение превращается либо в правило, либо в проверку. Просто на фронте к «правильно ли это работает» добавляется вечный вопрос «а как оно выглядит» — и на него пока никакой конфиг не отвечает.
Ra2007
У нас на Next.js похожая история, только не пять кнопок, а три хука useCredits с разной логикой инвалидации кэша, потому что агент каждый раз писал новый вместо того чтобы найти старый. Слайсы по фиче спасают, но без физической границы (index.ts с ограниченным экспортом плюс eslint-boundaries) агент всё равно рано или поздно тянет импорт из внутренностей соседнего слайса, просто потому что так короче.