Агент отчитался: экран настроек готов. Скриншот приложен, поля редактируются, после сохранения выскакивает тост «Сохранено». Я нажал F5 — и все настройки вернулись к дефолтным. Бэкенда под этой формой не существовало. Агент выяснил это в первые минуты работы над задачей — и вместо того чтобы сказать мне, молча положил данные в локальный стейт и нарисовал тост.
В первой статье я рассказывал, как пет-проект, который пишут LLM-агенты, за три месяца доехал от стартапа до болота — и как бэкенд лечился чистой архитектурой с автоматическим контролем границ. Во второй — как тем же самым лечился фронтенд. Рецепт в обеих сводился к одному: договорённость, которую проверяет только совесть, для агента не существует; хочешь, чтобы правило жило, — преврати его в ошибку сборки.
Обе статьи закрывали деградацию внутри одной кодовой базы. А тост «Сохранено» приехал из места, куда ни один из тех рецептов не достаёт: со шва между кодовыми базами. Про этот шов и поговорим.
Контроль границ, на котором держались обе прошлые статьи, устроен одинаково на любом стеке:
Вы объявляете границы: слои, контексты, публичные API модулей.
Анализатор строит по коду граф реальных зависимостей.
Сверяет его с объявленным.
Расхождение — ненулевой код выхода на pre-commit и в CI.
Работает прекрасно — но ровно до тех пор, пока работает язык. Анализатор видит то, что попадает в граф: импорт, наследование, аннотацию. HTTP-вызов в граф не попадает. Между фронтендом и бэкендом нет ни импорта, ни типа, ни ссылки — есть сетевой запрос, склеенный из строки с путём и надежды, что на той стороне всё как договаривались. Компилятор бэкенда не подозревает о существовании фронтенда, компилятор фронтенда — наоборот. Для любого статического анализа на этом месте просто ничего нет.
Хуже того: я сам заложил эту мину и сам же её задокументировал. В чек-листе внедрения из второй статьи есть пункт: «поднять генерацию API-клиента из OpenAPI-спеки бэкенда». Звучит разумно, я так и жил. Только спека при этом рождалась из аннотаций контроллеров — фреймворк собирал её из кода на лету, а фронт генерировал из неё клиент:
код бэкенда → спека → код фронтенда
Это code-first. Спека здесь — производная кода, а производная кода не может поймать в нём ни одной ошибки: код не бывает неправ относительно самого себя. Что бы бэкенд ни делал, спека прилежно опишет это как норму, а фронт прилежно под это сгенерируется. Стрелка смотрела не туда — но чтобы объяснить, куда она должна смотреть, придётся сходить в 2005 год, и мы туда обязательно сходим.
Сначала — место действия. Всё дальнейшее происходит на втором пет-проекте: сервис семейных финансов, Java 21 / Spring Boot, DDD/CQRS, монорепо, фронт на Next.js. Первый проект (AI-обработка фото, Symfony + Next.js) в этой статье почти не появится: на нём я настрадался, на втором — применял выводы. Масштаб: 660 коммитов, 148 операций в API, спека на 6000 строк. Пишет всё по-прежнему агент — и, к слову, быстрее, чем раньше: чем плотнее проект обвешан проверками, тем увереннее агент едет и тем быстрее втыкается в следующую неохраняемую границу. Путь «медовый месяц → похмелье», занявший на первом проекте три месяца, здесь уложился в две недели. Похмелье пришло откуда не ждали — со шва.
Record<string, unknown>, или типизация вслепую. Фронтовому агенту нужна форма ответа эндпоинта. Он её не знает — и типизирует «как-нибудь»:
export async function fetchCreditReport(
familyId: string,
importId: string,
): Promise<Record<string, unknown>> {
...
return result as unknown as Record<string, unknown>;
}
Формально типизировано, фактически — any в костюме. Каждое обращение к полю такого объекта дальше по коду написано наугад, и ошибка в нём всплывёт только в рантайме. У меня таких обёрток накопилось около сорока — и целое семейство эндпоинтов статистики прожило в этом виде до самой миграции.
Свободный object — дыра, оформленная как контракт. Один эндпоинт принимал тело как JsonNode, другой возвращал Map<String, Object>. Фреймворк добросовестно вывел это в спеку:
requestBody:
content:
application/json:
schema:
type: object
Генератор клиента так же добросовестно превратил это в unknown. Получился типизированный хук, внутри которого пустота — худший вариант из возможных: выглядит как контракт, не гарантирует ничего.
Две правды об одной сущности. DTO на Java и интерфейс на TypeScript, описывающие одно и то же, написаны разными сессиями агента с разницей в несколько дней. Пока совпадают — всё хорошо. Потом на бэке поле переименовали, и фронт узнал об этом в браузере у пользователя: для анализатора бэкенда фронта не существует, для анализатора фронта — наоборот.
Симуляция бэкенда. Тот самый тост «Сохранено» — и он был не один. Модалка безопасности показывала список устройств и активных сессий из локального файла с демо-данными. Экран вкладов считал доходность калькулятором на фронте, потому что серверного ресурса депозитов не существовало. Это самый дорогой паттерн из четырёх: агент не сообщает, что бэкенда нет, — он его имитирует, и экран неотличим от готового, пока не нажмёшь F5. Такие места я находил уже после того, как успевал забыть о задаче и принять экран за рабочий.
Общее у всех четырёх: ошибка не имеет адреса. Ни компилятор, ни линтер, ни архитектурный тест — ни на одной из сторон — не считает происходящее нарушением. Все проверки зелёные. Продукт сломан.
В команде людей шов держится на социальном механизме. Фронтендер, которому непонятна форма ответа, идёт к бэкендеру — не из дисциплины, а потому что угадывать дороже, чем спросить.
У агента экономика обратная. «Спросить» для него дорогая операция, «сгенерировать» — дефолтная. Не хватает данных — достроит правдоподобное, и правдоподобное выйдет убедительным: createdAt, items, total — модель прекрасно знает, как обычно выглядят такие ответы. Она не знает, как выглядит ваш.
Можно, конечно, честно: чтобы узнать форму ответа, фронтовому агенту нужно прочитать контроллер, хендлер, View-объект, мапперы — пять-десять файлов на чужом для его задачи языке. Но задача сформулирована «сделай экран», а не «изучи бэкенд», и дешёвый путь побеждает: агент угадывает по имени эндпоинта.
Отсюда центральная мысль статьи:
Контракт — это сжатие контекста на границе. Тридцать строк схемы вместо десяти файлов чужого стека.
Спека — единственный артефакт, где контексты двух агентов пересекаются; всё остальное каждый видит только со своей стороны. И когда этот артефакт есть, дешёвый путь и правильный наконец совпадают: прочитать схему проще, чем угадать.
Остаётся вопрос enforcement. Правило «фронт и бэк должны совпадать» — такая же джентльменская договорённость, как «не инжекти репозиторий в домен», а LLM, как мы выяснили ещё в первой статье, не джентльмен. Только на шве enforcement нельзя получить анализом кода — анализатору там нечего анализировать. Нужен общий артефакт, из которого обе стороны выводятся механически.
Что возвращает нас лет на двадцать назад.
Разработчики, заставшие нулевые, уже поняли, куда я клоню.
В девяностые была CORBA с её IDL: интерфейс описывался на отдельном языке, из описания генерировались стабы для C++, Java, чего угодно. В нулевые — SOAP, WSDL и XSD: описываешь сервис, натравливаешь wsdl2java — получаешь клиента и серверный интерфейс. Контракт был первичным артефактом, код — производным. Никто это не любил: XML, многословный тулинг, ломавшийся от неправильного namespace, слово «энтерпрайз» в худшем его значении. Но у мучения было свойство, которое мы потом выплеснули вместе с водой: сторона, разошедшаяся с контрактом, не компилировалась.
В десятые пришёл REST, а с ним Swagger — и перевернул стрелку. Спека стала выводиться из кода: развесил аннотации на контроллеры — получил документацию. Я радовался вместе со всеми. Больше никакого рассинхрона между документацией и реальностью — документация теперь всегда правдива, потому что она и есть код!
Code-first работал. Но не потому, что был технически лучше, а потому, что потребителем контракта был человек. Человек открывал Swagger UI, читал, понимал намерение — и адаптировался. Видел type: object — хмыкал и шёл читать код или дёргать коллегу. Расхождение между документом и намерением компенсировалось головой читателя.
Теперь читатель сменился. Агент намерений не восстанавливает — он продолжает образец. Если в схеме type: object, то в его картине мира там действительно может быть что угодно, и он с чистой совестью напишет unknown. Он не хмыкнет и не пойдёт спрашивать — некому и незачем.
Contract-first возвращает единственное, что было по-настоящему ценно в WSDL, — направление проверки. Спека пишется как утверждение о намерении, обе стороны выводятся из неё механически, и расхождение любой из них с контрактом — красная сборка в момент написания кода, а не сюрприз в проде.
Замечу, что маятник качнулся не для всех: gRPC с Protobuf и GraphQL с его SDL от contract-first никогда и не уходили — там схему нельзя не написать. И, по моим наблюдениям, агенты в этих экосистемах заметно увереннее. Не потому что протоколы лучше — потому что схема обязательна.
Мораль ретроспективы не в том, что раньше было лучше. Раньше было хуже, WSDL был отвратителен. Мораль в том, что от contract-first мы отказались ради удобства читателя — а читатель сменился. Со статической типизацией уже случилась ровно такая же история: её выбросили из скриптовых языков как бюрократию и вернули в JavaScript через TypeScript, когда проекты перестали помещаться в голову. Контракт возвращается, когда проект перестал помещаться в контекст.
Теперь конкретика. Источник правды — один файл в репозитории:
docs/api/
├── openapi.yaml # единственный источник правды
├── openapi.json # синхронная копия для тулинга, НЕ альтернативный контракт
├── .spectral.yaml # линтер контракта
└── contract-change-process.md # процесс изменения
Из него на этапе generate-sources собираются серверные интерфейсы:
<plugin>
<artifactId>openapi-generator-maven-plugin</artifactId>
<executions>
<execution>
<id>generate-api-interfaces</id>
<phase>generate-sources</phase>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/../../docs/api/openapi.yaml</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>com.denci.openapi.api</apiPackage>
<schemaMappings>...</schemaMappings>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<skipDefaultInterface>true</skipDefaultInterface>
<useSpringBoot3>true</useSpringBoot3>
<useTags>true</useTags>
<useResponseEntity>true</useResponseEntity>
<annotationLibrary>none</annotationLibrary>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
Две опции здесь важнее остальных. interfaceOnly — генерируются только интерфейсы, без заглушек реализации. skipDefaultInterface — методы без default-тел: не реализовать метод контракта нельзя, это ошибка компиляции.
Контроллер такой интерфейс реализует:
/**
* HTTP mappings, parameters and response types are inherited from the generated
* {@link TasksControllerApi}, which is produced from the OpenAPI contract
* (docs/api/openapi.yaml). Any deviation of a method signature from the contract
* is a compile error. Business annotations such as {@link FamilyAccess} stay here.
*/
@RestController
public class TasksController implements TasksControllerApi {
@Override
@FamilyAccess(FamilyAccess.AccessType.COMMAND)
public ResponseEntity<CreateTaskResponse> createTask(String familyId, CreateTaskRequest body) {
...
}
}
Посмотрите, чего в этом классе нет: @RequestMapping, @PostMapping, @PathVariable, @RequestBody. Пути, параметры, типы ответов — всё унаследовано от сгенерированного интерфейса. Осталось только то, что контракту не принадлежит: @RestController и бизнес-аннотации вроде @FamilyAccess.
Сигнатура метода перестала быть выбором автора и стала обязательством. Агент решил вернуть ответ не так, как записано в спеке, — не скомпилируется. Добавил параметр — не скомпилируется. Переименовал поле, не тронув спеку, — не скомпилируется.
Javadoc в шапке класса написан, кстати, не для людей. Это сообщение агенту, лежащее ровно в том файле, который он откроет, когда полезет править контроллер: маппинги наследуются, отклонение сигнатуры — ошибка компиляции, бизнес-аннотации остаются здесь. Микроинструкция в точке применения работает лучше, чем абзац в общих правилах проекта, который ещё надо вспомнить.
В конфиге выше я свернул один параметр в многоточие — разворачиваю, потому что это самая недооценённая часть всей конструкции.
По умолчанию генератор создаёт для каждой схемы контракта собственный DTO-класс. У вас уже есть TaskView в слое приложения — генератор кладёт рядом свой TaskView. Дальше кто-то (угадайте кто) начинает писать мапперы из одного в другой, и вы получаете слой перекладывания данных, который нужно синхронизировать руками, — то есть ровно ту болезнь, от которой лечились.
schemaMappings говорит генератору: не создавай тип, возьми мой.
TaskView=com.denci.backend.core.tasks.application.view.TaskView,
CreateTaskRequest=com.denci.backend.app.api.http.tasks.request.CreateTaskRequest,
FamilyView=com.denci.backend.core.family.application.FamilyView,
...
Схема контракта отображается на существующий тип проекта. Никакого промежуточного слоя: то, что возвращает хендлер, и есть то, что описано в контракте.
Оборотная сторона, чтобы не выходила реклама: в реальном pom.xml это 126 пар «схема=FQCN», 10 757 символов, и всё — одной строкой, потому что переносы плагин не переваривает. Читать невозможно, ревьюить тем более, а конфликт слияния в этой строке — конфликт во всём контракте сразу. Живёт эта строка только потому, что редактирует её агент. Начинал бы заново — генерировал бы параметр скриптом из отдельного человеческого конфига.
Рантайм-генератор спеки из проекта не выкинут — Swagger UI в деве удобен. Но роль источника у него отобрана, о чём напоминает комментарий прямо в Makefile:
# Refresh the OpenAPI contract baseline from the running backend (bootstrap only;
# docs/api/openapi.yaml is the hand-maintained source of truth under API-First).
openapi-export:
$(COMPOSE) exec -T backend curl -s http://localhost:8080/api-docs.yaml > docs/api/openapi.yaml
Комментарий здесь важнее команды. Без него агент однажды решит, что расхождение спеки с кодом чинится «обновлением спеки из бэкенда», выполнит экспорт — и молча вернёт проект в code-first, затерев намерение фактом. По-хорошему, такую цель вообще стоит прятать в скрипт с интерактивным подтверждением.
Контракт охраняет шов, но внутренние границы бэкенда — слои, контексты, направление зависимостей — по-прежнему нуждаются в собственном страже. На первом проекте им был deptrac, на втором, вслед за сменой языка, — ArchUnit. Разница между ними оказалась интереснее, чем я ожидал: это два разных класса инструментов.
deptrac декларативен: слои описываются коллекторами, разрешения — правилами в YAML. Сила в том, что конфиг читается целиком за минуту — и человеком, и агентом, которому он попадёт в контекст. Потолок в том, что выразить можно ровно один вид утверждений: кто кого может видеть.
ArchUnit — это обычные тесты на языке проекта, а значит, выразить можно что угодно. Рядом с ожидаемыми «core не зависит от app» и «контексты не видят друг друга напрямую» у меня живут правила, которые в YAML не записываются в принципе:
@ArchTest
static final ArchRule controllers_must_stay_thin = classes()
.that().resideInAPackage("..app.api..")
.should(notExceedSourceLines(200));
«Контроллеры должны быть тонкими» — вечное благое пожелание из гайдлайнов. Здесь это падающий тест. А вот правило посерьёзнее:
@ArchTest
static final ArchRule families_mappings_require_family_access = classes()
.that().resideInAPackage("..app.api..")
.should(haveFamilyAccessOnAllMappings())
.because("all /api/families mappings must enforce family access unless explicitly whitelisted");
Каждый HTTP-маппинг под /api/families обязан нести аннотацию @FamilyAccess — кроме явного белого списка: публичный просмотр приглашения и банковский вебхук с HMAC-подписью. Это уже не про слои — это авторизационный инвариант, поднятый до архитектурного теста. Забытый агентом @FamilyAccess — не стилистика, а доступ к чужим финансам.
Чем больше правил живёт в исполняемом коде, тем больше инвариантов проекта можно превратить в ошибку сборки, включая те, что вовсе не про зависимости. Но у выразительности есть цена:
Декларативное правило нельзя написать неправильно молча. Кодовое — можно.
YAML либо покрывает класс, либо нет, и это видно глазами. Код может содержать условие, при котором правило тихо выключает само себя. Запомните эту фразу — она ещё выстрелит.
Фронт генерируется из того же файла:
export default defineConfig({
denci: {
input: { target: "../../docs/api/openapi.yaml" },
output: {
mode: "tags-split",
client: "react-query",
httpClient: "fetch",
target: "src/shared/api/generated/endpoints",
schemas: "src/shared/api/generated/model",
override: {
mutator: { path: "src/shared/api/generated/mutator.ts", name: "orvalFetch" },
},
},
},
denciZod: {
input: { target: "../../docs/api/openapi.yaml" },
output: {
mode: "tags-split",
client: "zod",
target: "src/shared/api/generated/zod",
},
},
});
Выхода два, и это не украшательство. Первый — хуки TanStack Query с TypeScript-моделями: контракт времени компиляции, проверяющий, что фронт ожидает правильное. Второй — zod-схемы: контракт времени выполнения, проверяющий в тестах и деве, что бэк отдаёт правильное. Утверждения разные, нужны оба.
Мутатор orvalFetch — единственное место фронтенда, где живут базовый URL, авторизация и обработка ошибок; рукописный fetch запрещён. А самая полезная строчка во всей фронтовой части выглядит так:
"openapi:check": "orval --config orval.config.ts && prettier --write \"src/shared/api/generated/**/*.ts\" && git diff --exit-code -- src/shared/api/generated"
Перегенерируй — и упади, если результат отличается от закоммиченного. Одна строчка ловит два самых частых греха агента. Первый: увидел ошибку типа в сгенерированном файле — починил её там же (это его естественный рефлекс, generated-код для него такой же код, как остальной). Второй: поправил спеку — забыл регенерировать. Оба означают дрейф клиента от контракта, оба теперь — красный CI.
Само сгенерированное при этом выведено из-под всех остальных проверок фронта — линтера, поиска мёртвого кода, анализатора границ, i18n-гварда:
{ "ignore": ["src/shared/api/generated/**"] }
Иначе гейты краснеют на машинном коде, и агент кидается «чинить» генерацию.
Итог для агента: ему негде выдумывать поля — типы приезжают из контракта. А изменение контракта превращается в веер ошибок tsc во всех отставших местах, и это подарок, а не проблема: ошибка компиляции — самый надёжный способ доставить знание в контекст агента в момент, когда оно нужно. Он идёт по списку и чинит сам, без единого моего слова.
Раз спека стала источником правды, к ней применимо всё, что применяется к коду: линтер, версионирование, защита от ломающих изменений.
Линтер. Spectral с небольшим ruleset: осмысленный title, description, запрет trailing slash в путях и — главное — semver в версии:
info-version-semver:
description: "API info version must follow semantic versioning (MAJOR.MINOR.PATCH)."
given: $.info.version
severity: error
then:
- function: pattern
functionOptions:
match: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
Правил немного, и дело не в их количестве: спека перестала быть свободным текстом и стала объектом линтинга.
Политика ломающих изменений. Скрипт в CI диффует спеку рабочего дерева против master; классификацию изменений делает oasdiff, решение принимает политика:
// Rules:
// - If the spec on master does not exist, skip (first PR or branch rename).
// - If the current info.version is not a valid semver string, fail.
// - If no breaking changes: succeed regardless of version bump.
// - If breaking changes: fail unless current major > master major (MAJOR bump).
// A MAJOR bump implies breaking changes are accepted by policy → exit 0.
Зачем это в проекте, который пишет агент: агент никогда сам не осознает, что сломал контракт. Он честно выполнил «переименуй поле», регенерировал обе стороны, всё скомпилировалось, тесты зелёные — работа с его точки зрения безупречна. Что при этом сломались все уже выкаченные потребители, он не скрыл — он об этом не подумал: в его контексте нет ни мобильного приложения, ни внешних клиентов. Классификация «breaking / non-breaking» — внешний сигнал, который иначе в его картину мира не попадает.
Процесс. Документ с таблицей «что считается ломающим» и порядком действий: правим спеку → бампим версию → регенерируем обе стороны → чиним потребителей → один атомарный коммит. Пункт «PR ревьюят бэкендер и фронтендер» в соло-проекте, честно говоря, фикция. Но одна привычка оттуда работает и в одиночку: diff спеки — обязательная часть моего собственного ревью. Он на порядок читаемее диффа кода: двадцать строк YAML говорят о смысле изменения больше, чем четыреста строк Java и TypeScript вокруг. Из всего, что агент наделал за сессию, первым делом я смотрю именно их.
Всё перечисленное собрано в make check вместе с проверками из прошлых статей и висит на pre-commit-хуке, который версионируется в репозитории.
Осталась симуляция — тот самый тост «Сохранено». Её не ловит ни один конфиг: формально ничего не нарушено, код фронта чист, контракт не тронут. Лечится она правилом в инструкциях проекта:
Правило сохранения UI-операций: если запрошенное UI-действие не имеет бэкенд-поддержки в API-контракте, необходимо расширить контракт (OpenAPI) и реализовать соответствующий backend path, а не убирать UI-элемент и не симулировать локальное сохранение, если пользователь явно не просит иное.
Формулировка кажется очевидной, пока не поймёшь, зачем она написана. Дефолтная стратегия агента — «сделать так, чтобы выглядело выполненным», и у него есть два кратчайших пути к зелёному прогону: убрать кнопку («в задаче не сказано, что она обязательна») или сохранить в локальный стейт и показать тост. Оба выглядят как выполненная задача, оба катастрофичны. Правило перекрывает оба и оставляет единственный выход — расширить контракт.
Но правила мало: разрыв нужно ещё видеть. Для этого в проекте живёт карта «UI ↔ контракт», где у каждого пользовательского сценария есть статус: A — контракт есть и используется, B — контракт есть, но UI работает на локальной логике, C — контракта не хватает.
| Поток | Статус | Комментарий | Приоритет |
| 2FA и устройства/сессии | B | модалка есть, данные — demo | P2 |
| Депозиты: список/projection | C | локальный калькулятор; ADR-0008 | P1 |
| Внешние интеграции | C | UI показывает «недоступно» | P1 |
Это дешёвый заменитель фронтендера, стучащегося к бэкендеру: разрыв получает адрес и статус. Заметьте строку про депозиты — локальный калькулятор там по-прежнему есть, но он записан. Разница между симуляцией, о которой знает документ, и симуляцией, о которой не знает никто, — это разница между техдолгом и миной.
Проект начинался с code-first, и выгруженная из работающего бэкенда спека была не контрактом, а слепком: свободные object, ответы «200 OK» без тела, теги вида tasks-controller — прямой отпечаток имён Java-классов (кое-где он жив в спеке до сих пор).
Перевод занял три фазы, и порядок здесь важнее содержания. Сначала — замкнуть контур на одном модуле, от спеки до UI, со всеми мелочами. Не ради модуля, а ради образца: дальше агенту показываешь пальцем — «сделай с ledger так же, как сделано с tasks». Один вылизанный образец экономит десятки итераций объяснений — это вообще главный приём работы с LLM на больших однотипных задачах. Потом — ужесточить спеку, заткнув дыры code-first: скучная работа ровно того сорта, который агент делает хорошо, а человек плохо. И только потом — раскатывать по проекту партиями, по ограниченным контекстам, с тестами на каждой партии, а не одним героическим заходом.
Итог: 37 контроллеров из 39 реализуют сгенерированные интерфейсы, версия контракта дошла до 2.0.0 — через настоящий мажорный бамп, которого потребовала политика ломающих изменений.
Показательно, что понадобился ещё один заход — уже после «завершения» миграции. Я прошёлся по фронту grep’ом и нашёл те самые сорок выживших кастов. Винить фронтового агента было не в чем: почти все они сидели поверх дыр в самой спеке. Create-эндпоинты возвращали свободный object, потому что контроллеры отвечали Map.of("id", ...); генератор честно выдавал { [key: string]: unknown }, и фронту ничего не оставалось, кроме как выковыривать поля руками. Где контракт слаб, генерация слабость не лечит — она её честно транслирует на другую сторону.
Лечилось по рецептам этой же статьи. Вместо шестнадцати свободных ответов — две общие схемы, CreatedIdResponse { id } и StatusResponse { status }; на бэке типизированные record’ы вместо Map.of(); регенерация обеих сторон — и касты снялись сами, потому что типы наконец приехали. А чтобы обёртки не отросли обратно (агент обязательно попробует — каст для него дешевле, чем поход в спеку), в проверки фронта добавился запрет as unknown as в API-слое: ещё одно пойманное нарушение, превращённое в правило сборки. Свободный object остался только там, где ему место, — вебхуки и health-check; и часть потоков в карте UI по-прежнему в статусе C, о чём карта честно и сообщает.
Этот случай я нашёл, когда собирал материал для статьи, — и на момент публикации он ещё жив в репозитории.
Вспомните правило families_mappings_require_family_access — то, что требует @FamilyAccess на каждом семейном эндпоинте. Вот как оно определяет, что перед ним семейный контроллер:
private boolean checkRequestMapping(JavaClass javaClass) {
var rm = javaClass.tryGetAnnotationOfType(
"org.springframework.web.bind.annotation.RequestMapping");
if (rm.isPresent() && hasFamiliesPath(rm.get().as(RequestMapping.class).value())) {
return true;
}
// ...то же самое для @RequestMapping на методах
return false;
}
Правило ищет аннотацию @RequestMapping с путём /api/families, физически присутствующую на классе или его методах. ArchUnit не резолвит аннотации, унаследованные от интерфейса.
А что мы сделали при переходе на contract-first? Убрали MVC-аннотации из контроллеров — маппинги переехали в сгенерированные интерфейсы. Счёт: @RestController в проекте — 39, из них с собственным @RequestMapping — один. Для остальных тридцати восьми checkRequestMapping возвращает false, и правило молча выходит, не проверив ничего. Тест зелёный. Проверки авторизации нет. Она была, я её написал, я на неё полагался — и она умерла в тот момент, когда я улучшал архитектуру. Никто мне об этом не сообщил: не существует проверки, которая проверяет, что проверка проверяет.
Вот и выстрелила фраза из середины статьи: декларативное правило нельзя сломать молча, кодовое — можно. И вот обобщение, ради которого весь этот раздел:
Инварианты имеют привычку жить на тех самых артефактах, которые кодогенерация забирает себе.
А сгенерированное мы своими руками вывели из-под всех чекеров — на бэке по пакету, на фронте по папке. И вывели правильно! Иначе гейты краснеют на машинном коде. Но пересечение двух разумных решений дало зону, где правило существует, выполняется и не проверяет ничего. Под LLM это опаснее обычного: агент видит зелёный прогон и считает инвариант живым; я вижу тот же прогон и считаю так же. Отрицательный результат проверки неотличим от её отсутствия — для нас обоих.
Что делать. Опирать правила на то, что осталось в вашем коде после генерации, — здесь надёжный признак implements *ControllerApi, он никуда не денется. Ещё лучше — валидировать инвариант против самой спеки: пути и так лежат в openapi.yaml, это более прямой источник, чем аннотации. И общая гигиена: после каждой миграции, забирающей артефакт в генерацию, пройтись по проверкам с вопросом «на чём именно ты держалась?» — а каждое архитектурное правило хотя бы раз сломать нарочно и убедиться, что оно краснеет. Правило, которое ни разу не падало, не доказано. Моё не падало никогда, и я считал это признаком хорошей дисциплины.
Спека на 6000 строк — ещё один артефакт, который надо ревьюить и в котором заводится бардак. Сгенерированный код в git обязателен — без него не проверить дрейф, — но раздувает диффы в разы. Цикл изменения удлинился: добавить одно поле — это спека, регенерация двух сторон, версия, потребители, атомарный коммит; на быстром прототипировании такой налог убил бы меня. И OpenAPI объективно слаб на нестандартном: multipart, бинарные загрузки, вебхуки, стриминг описываются неудобно — мои оставшиеся дыры в основном там.
Contract-first окупается там, где есть шов между двумя контекстами — человеческими или агентскими. Одна кодовая база, один агент, один потребитель API — вы платите налог, не получая страховки. Прототип на выброс — тем более: там скорость важнее, а деградировать нечему.
Порог, после которого пора, по моему опыту такой: фронт и бэк перестали помещаться в один контекст агента. Пока агент правит обе стороны в одной сессии и держит обе формы данных в голове, шов держится сам. Как только сессии разъехались — начинается всё, что описано в начале статьи.
Проект под LLM гниёт первым там, где не проверяет ни один компилятор. Анализ кода видит импорты и наследование; HTTP-вызов для него не существует. Шов между фронтом и бэком остаётся открытым, даже когда обе половины идеально чисты внутри.
Контракт — это сжатие контекста на границе. Тридцать строк схемы вместо десяти файлов чужого стека; дешёвый путь агента совпадает с правильным.
Code-first документирует факт, contract-first проверяет намерение. Производная кода не ловит ошибок в коде. Под LLM стрелка обязана смотреть в обратную сторону: код выводится из контракта.
Кодогенерация с обеих сторон — и есть enforcement на шве. Расхождение становится ошибкой компиляции, а ошибку компиляции агент чинит сам.
Спека — тоже код: линтер, semver, политика ломающих изменений. «Breaking / non-breaking» — внешний сигнал; сам агент никогда не поймёт, что сломал чужих потребителей.
Самое дорогое поведение агента на шве — не ошибка, а симуляция. Разрыв контракта должен иметь адрес и статус в живом документе, иначе агент закроет его демо-данными и тостом «Сохранено».
Кодогенерация создаёт слепую зону. Всё, что уехало в сгенерированный код, вышло из-под ваших проверок — вместе с инвариантами, которые на нём держались. Правило, которое ни разу не падало, не доказано: ломайте нарочно.
Ничего нового мы не изобрели. Это WSDL, вернувшийся под другим соусом: от contract-first отказались ради удобства читателя, а читатель сменился.
Чек-лист внедрения:
[ ] Один файл спеки в репозитории, объявленный источником правды; рантайм-экспорт из фреймворка — только для бутстрапа, с предупреждением рядом.
[ ] Серверные интерфейсы генерируются из спеки на этапе сборки, контроллеры их реализуют; доменные типы подставлены через маппинг схем, чтобы не завёлся параллельный DTO-зоопарк.
[ ] Клиент и рантайм-схемы фронта генерируются из той же спеки; рукописный HTTP-вызов и нетипизированные касты поверх сгенерированных типов запрещены проверкой.
[ ] Проверка дрейфа: перегенерировать в CI и упасть, если закоммиченное отличается.
[ ] Сгенерированное — билд-артефакт: исключено из остальных чекеров, руками не правится.
[ ] Линтер спеки и политика ломающих изменений с semver-бампом относительно основной ветки.
[ ] Правило в инструкциях проекта: нет контракта под UI-операцию — расширяем контракт, а не симулируем и не прячем кнопку.
[ ] Живая карта «UI ↔ контракт» со статусами: разрыв обязан быть виден.
[ ] После миграции пройтись по архитектурным правилам: не уехало ли их основание в сгенерированный код. Каждое правило сломать нарочно и убедиться, что краснеет.
[ ] Всё это — одной командой, на pre-commit и в CI.
Если сжать три статьи до одного предложения: приём всё это время был один — превращать договорённости в ошибки сборки, менялись только границы. Внутри кодовой базы договорённости сторожит анализ графа зависимостей; на шве между кодовыми базами анализ бессилен по построению, и там стражем становится общий контракт, из которого обе стороны выводятся механически. Инструменты — расходники, каждый живёт ровно столько, сколько живёт стек. Не меняется только вопрос, который стоит задать своему проекту на любом стеке: какие договорённости здесь держатся на честном слове — и что случится, когда их начнёт нарушать тот, у кого честного слова нет?