Обычно API рождается из кода: написали контроллеры, а описание (если оно есть) сгенерировали потом. API-first переворачивает порядок: сначала договариваемся о контракте, и только потом пишем код по нему. Разберём, зачем так и как это устроено.
Разница между двумя порядками видна сразу, если разложить работу по тактам; такт здесь это одна итерация команды, обычно неделя или две.
Бэкенд и фронт пишут столько же — по три такта. Разница в том, что при contract-first фронт уже с такта 2 работает по моку из контракта, а не ждёт готового бэкенда; такт на сам контракт — цена подхода.
Что такое API-first простыми словами
API-first — подход, при котором контракт API проектируется первым, до реализации, и становится главным артефактом, по которому работают все стороны. Контракт — это машиночитаемое описание: какие есть эндпоинты, какие параметры и тела запросов, какие ответы и коды ошибок. Стандартный формат такого описания для REST — OpenAPI (YAML или JSON).
Короткая формула: сначала контракт, потом код. Контракт — это договор между теми, кто API предоставляет, и теми, кто его потребляет.
Противоположность — code-first: пишем код, а описание API получаем из него (аннотации, рефлексия). Оба варианта дают на выходе OpenAPI-документ, но порядок и источник правды разные.
Зачем это нужно
Фронтенд ждёт три недели, пока бэкенд допишет эндпоинт, а потом ещё неделю переделывает под то, что получилось не так, как договаривались на словах. Контракт, готовый раньше кода, убирает обе недели. Фронт поднимает мок по спецификации и пишет интерфейс, пока бэкенд реализует логику: обе стороны работают одновременно.
Контракт один и машиночитаемый, поэтому не бывает «в коде одно, в документации другое»: документация генерируется из него же, как и DTO, интерфейсы контроллеров и клиенты, и шаблонного кода с расхождениями между сторонами становится меньше. Дизайн обсуждают на ревью YAML до первой строчки реализации, а поправить YAML дешевле, чем переписывать готовый код. И контракт сравнивают между версиями автоматически, ловя ломающие изменения в CI.
Contract-first и code-first
Оба пути ведут к OpenAPI-документу, но по-разному.
Contract-first — источник правды это OpenAPI YAML. Сначала пишем спецификацию руками, затем openapi-generator создаёт из неё DTO и интерфейсы. Контроллер реализует сгенерированный интерфейс (implements <Tag>Api), а ограничения из схемы — required, minLength, maximum — превращаются в аннотации на полях DTO.
Про эти аннотации нужны две оговорки, иначе они не сработают.
Во-первых, аннотации сами по себе ничего не проверяют. Нужно, чтобы в сборке был spring-boot-starter-validation, а на параметре контроллера стояла @Valid — генератор её ставит, но если вы пишете контроллер руками, поставить придётся тоже руками. Без этого @NotNull останется просто буквами в исходнике.
Во-вторых, не всякий format из схемы становится проверкой. format: email действительно даёт @Email. А вот format: uuid даёт тип UUID, и никакой аннотации рядом не появляется — проверка тут не нужна, строка либо разобралась в UUID, либо запрос отвалился ещё на разборе тела. То же с format: date-time: получаете OffsetDateTime, а не аннотацию. Полезно помнить, когда ищете в сгенерированном коде проверку, которой там нет и не должно быть.
# фрагмент контракта: контракт первичен
paths:
/orders/{id}:
get:
operationId: getOrder
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: Заказ
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
Code-first — источник правды это код: классы DTO и аннотации, из которых описание API генерируется автоматически (в Spring это springdoc, в FastAPI — модели Pydantic, в Go — struct-теги). Быстрее на старте, но контракт здесь — следствие кода, а не договор.
Что выбрать: для публичного API и для нескольких команд, которым нужно договориться заранее, обычно лучше contract-first — контракт виден и стабилен до реализации. Для небольшого внутреннего сервиса, который пишет одна команда, code-first проще и его достаточно.
Из одного файла контракта получают четыре вещи, и каждая достаётся своей стороне: серверу интерфейсы, потребителю клиент, фронтенду мок, читателю документация.
Как выглядит процесс contract-first
- Пишем/правим OpenAPI-контракт — эндпоинты, схемы, ошибки, версии.
- Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
- Генерация —
openapi-generatorсоздаёт DTO + интерфейсы контроллеров для сервера и клиент для потребителя. - Реализация — контроллер реализует сгенерированный интерфейс; логика пишется внутри, контракт не переписывается руками.
- Проверка в CI — линт контракта и проверка обратной совместимости с предыдущей версией.
Важное правило команды: правки контракта идут в YAML, а не в сгенерированный код.
Первая ловушка: генератор по умолчанию пишет под Spring Boot 2
На этом спотыкается почти каждый, кто подключает openapi-generator впервые. Контракт написан, генерация настроена, сборка запускается — и падает пачкой ошибок вида «package javax.validation does not exist».
Причина в том, что генератор spring по умолчанию рассчитан на Spring Boot 2 и пишет импорты из старого пространства имён javax. В Spring Boot 3 всё это переехало в jakarta. Лечится одной настройкой:
openApiGenerate {
generatorName.set("spring")
inputSpec.set("$rootDir/api/openapi.yaml")
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.path)
configOptions.set(mapOf(
"useSpringBoot3" to "true", // без неё будут импорты javax.* и сборка упадёт
"interfaceOnly" to "true", // только интерфейсы и DTO, контроллер пишем сами
"useTags" to "true"
))
}
useSpringBoot3 заодно включает useJakartaEe, так что отдельно его указывать не нужно.
Вторая ловушка: где лежит сгенерированный код
Фраза «сгенерированные файлы перезаписываются при каждой сборке» верна не всегда — она верна ровно тогда, когда генерация настроена как шаг сборки, а результат кладётся в build/generated. Тогда каталог чистится вместе со всей сборкой, правка руками живёт до ближайшего clean, и правило соблюдается само.
А бывает иначе: генерацию запускают раз в неделю руками, результат кладут в src/main/java и коммитят в репозиторий. И тогда правка руками не исчезает — она живёт, пока кто-то не перегенерирует. Обычно это происходит через пару месяцев, в чужой ветке, и десяток аккуратно дописанных полей молча пропадает. Ищут потом долго: сборка не падала, тесты не краснели, поле просто перестало приходить.
Поэтому договоритесь один раз: сгенерированный код лежит в build/generated и в репозиторий не коммитится. Каталог добавляют в .gitignore и в исходники сборки:
sourceSets.main {
java.srcDir(layout.buildDirectory.dir("generated/openapi/src/main/java"))
}
tasks.compileJava { dependsOn(tasks.openApiGenerate) }
Тогда правка руками физически не переживёт следующую сборку — и вопрос «а можно я тут по-быстрому допишу» отпадает сам.
Как это собирается: генератор в Gradle
Описание лежит в репозитории сервиса, генератор создаёт из него интерфейсы и модели, контроллер их реализует. Всё остальное — детали, но именно они отнимают первый день.
Где лежит описание. src/main/resources/openapi/orders-api.yaml — внутри модуля, рядом с кодом, который его реализует. Тогда описание попадает в сборку, его видно в изменении вместе с кодом, и генерация не зависит от сети.
Подключение генератора.
plugins {
id("org.openapi.generator") version "7.10.0"
}
openApiGenerate {
generatorName.set("spring")
inputSpec.set("$rootDir/src/main/resources/openapi/orders-api.yaml")
outputDir.set("${layout.buildDirectory.get()}/generated/openapi")
apiPackage.set("ru.shop.api.generated")
modelPackage.set("ru.shop.api.generated.model")
configOptions.set(mapOf(
"interfaceOnly" to "true", // только интерфейсы, реализацию пишем сами
"useSpringBoot3" to "true", // jakarta.*, а не javax.*
"useTags" to "true", // группировка по тегам, а не один огромный интерфейс
"openApiNullable" to "false", // без JsonNullable, если он не нужен
"documentationProvider" to "none"
))
}
sourceSets.main {
java.srcDir("${layout.buildDirectory.get()}/generated/openapi/src/main/java")
}
tasks.named("compileJava") { dependsOn("openApiGenerate") }
Три настройки, из-за которых чаще всего мучаются. interfaceOnly — иначе генератор создаст и заготовки контроллеров, и они будут конфликтовать с вашими. useSpringBoot3 — без него сгенерируются импорты старого пространства имён, и код не соберётся. И подключение сгенерированного каталога в sourceSets плюс зависимость задачи компиляции — без них сборка «не видит» сгенерированные классы либо видит их только после второго запуска.
Как выглядит контроллер. Сгенерированный интерфейс описывает подписи, аннотации путей и типы; ваш класс его реализует:
@RestController
@RequiredArgsConstructor
public class OrdersController implements OrdersApi { // OrdersApi сгенерирован
private final CreateOrder createOrder;
private final OrderQueries queries;
@Override
public ResponseEntity<OrderResponse> createOrder(CreateOrderRequest request,
String idempotencyKey) {
OrderId id = createOrder.handle(request.toCommand(), idempotencyKey);
return ResponseEntity
.created(URI.create("/api/v1/orders/" + id.value()))
.body(queries.byId(id));
}
@Override
public ResponseEntity<OrderListResponse> listOrders(String status, Integer page, Integer size) {
return ResponseEntity.ok(queries.list(status, page, size));
}
}
Главное свойство этой схемы: подпись метода нельзя изменить, не изменив описание. Добавили параметр в контроллер — код не компилируется, пока параметр не появился в описании. Это и есть та самая дисциплина, ради которой всё затевалось; при генерации из кода она отсутствует.
Клиент из того же описания. Тем же генератором собирают клиента (generatorName = "java", библиотека по выбору) и публикуют его как артефакт — тогда потребитель не пишет обращения руками. Это отдельная задача сборки с другим выходным каталогом.
Чем проверяют контракт
Инструменты стоит назвать по именам, потому что «линт и проверка совместимости» звучит абстрактно.
spectral — линтер описания. Проверяет правила: у каждой ручки есть описание и пример, имена в одном стиле, коды ответов перечислены, нет ручек без тега, ошибки описаны единой схемой. Свои правила добавляют файлом настроек; запускают в сборке до генерации.
oasdiff (или openapi-diff) — сравнение двух описаний. Различает совместимые изменения и ломающие: убрали поле из ответа, добавили обязательный параметр, сузили тип, убрали значение перечисления, поменяли код ответа. В сборке сравнивают описание ветки с описанием основной ветки и падают на ломающем изменении внутри версии.
oasdiff breaking --base main/openapi/orders-api.yaml --revision openapi/orders-api.yaml --fail-on ERR
Проверка ответов по схеме. Линт и сравнение проверяют описание, а не поведение. Чтобы поймать расхождение описания и реальности, ответы интеграционных тестов валидируют против схемы: библиотека проверки (swagger-request-validator и подобные) подключается к тестам и падает, если ответ не соответствует описанию — лишнее поле, не тот тип, пропущенное обязательное.
Моки для потребителя. prism mock orders-api.yaml поднимает сервер, отвечающий по описанию, — потребитель начинает работу, не дожидаясь реализации. Оговорка: мок отвечает примерами из описания, поэтому качество примеров становится частью работы.
Как контракт и реализация разъезжаются
Главная беда contract-first не в том, что его трудно начать, а в том, что через полгода описание отличается от поведения. Три места, где это происходит.
Поля появляются мимо описания. Сериализатор отдаёт всё, что есть в объекте, а не то, что описано; добавили поле в модель ответа — оно поехало клиентам, в описании его нет. Ловится проверкой ответов по схеме в тестах (строгий режим, при котором лишнее поле — ошибка).
Коды ответов не совпадают. В описании 404 и 409, а обработчик ошибок отдаёт ещё и 422, о котором никто не написал. Ловится тем же способом: тест на каждую ветку ошибки плюс проверка, что код есть в описании.
Примеры врут. Пример в описании собран руками год назад и не соответствует формату. Лечится генерацией примеров из тестов (снимок реального ответа) или хотя бы проверкой примеров по схеме — spectral это умеет.
Отсюда практическое правило: contract-first без проверки ответов по схеме превращается в code-first с лишним файлом. Описание должно быть проверяемым, иначе оно документация, а не контракт.
Кто владеет контрактом и где он лежит
Вопрос, который решают один раз и потом с ним живут.
В репозитории сервиса (обычный выбор). Описание лежит рядом с реализацией, меняется вместе с ней, ревью одно. Потребитель получает его как артефакт: сборка публикует и описание, и сгенерированного клиента в хранилище артефактов с версией. Плюс — простота; минус — потребителю надо знать, где взять, и следить за версиями.
В отдельном репозитории схем (для организации с десятками сервисов). Все контракты в одном месте, общие правила линта, общие модели ошибок, единая история изменений. Плюс — видно всю картину и легко проверять правила централизованно; минус — изменение контракта требует изменения в двух репозиториях, и это замедляет.
Подключение к сервису делают либо зависимостью на артефакт описания, либо подмодулем репозитория схем. Подмодуль дешевле в настройке и неудобен в работе (легко забыть обновить); артефакт требует публикации, зато даёт версионирование.
Кто владеет: команда сервиса, а не команда потребителя и не архитектор отдельно. Потребитель участвует в обсуждении до принятия, и это оформляют как ревью изменения описания — там же, где обсуждают всё остальное.
Чем платят за contract-first
Четыре строки про «когда избыточен» стоит развернуть в честные издержки — иначе решение принимается на вере.
Ревью описания. Каждое изменение API требует ревью ещё одного файла, и обсуждение формы (имена, структура, коды) занимает время до начала работы. Это и есть главная выгода (обсудить до кода) и главная цена (медленнее старт).
Конфликты при слиянии. Большое описание в одном файле — это файл на несколько тысяч строк, в который одновременно пишут пять человек. Конфликты слияния в YAML разбирать неприятно; лечится разбиением на файлы по тегам и склейкой при сборке, но это отдельная настройка.
Генератор неудобен на краях. Загрузка файлов (multipart), потоковые ответы, полиморфные схемы (oneOf, discriminator), нестандартные форматы — всё это генератор описывает беднее, чем хотелось бы: получаются неудобные подписи, лишние обёртки, иногда просто не то. Обычный выход — описать ручку в контракте, а реализовать её руками, вне сгенерированного интерфейса.
Дисциплина против скорости. Прототип, который выкидывают через две недели, contract-first только замедляет. Правило простое: контракт нужен там, где по ту сторону другая команда или внешний клиент; там, где обе стороны в одном репозитории и выкатываются вместе, он избыточен.
Компромисс: генерировать описание из кода и публиковать как контракт
Распространённый средний путь, который стоит назвать явно: сервис пишется как обычно (code-first), описание генерируется из аннотаций, но затем публикуется и проверяется как контракт — линт, сравнение с предыдущей версией, запрет ломающих изменений в сборке.
Что это даёт: скорость code-first и главную гарантию contract-first (ломающее изменение не проходит незамеченным). Чего не даёт: обсуждения формы до реализации — описание появляется уже после того, как решения приняты.
Практически это выглядит так: генерация описания задачей сборки, публикация как артефакта, oasdiff против опубликованной версии, падение сборки на ломающем изменении. Для внутренних сервисов этого обычно достаточно; для публичного API и для контрактов между командами остаётся полноценный contract-first.
Контракт в работе: моки, версии, границы
Моки и параллельная работа
Главный практический выигрыш API-first — мок прямо из контракта. Инструменты (например, Prism) поднимают фейковый сервер по OpenAPI-файлу: он отвечает примерами из спецификации. Фронтенд и потребители-сервисы начинают интеграцию сразу, не дожидаясь готового бэкенда. Когда реальный сервис готов, переключаются с мока на него — контракт-то один и тот же.
Версионирование и эволюция контракта
Контракт живёт долго, и его нужно менять не ломая потребителей. Базовые правила: добавлять необязательные поля можно, удалять или переименовывать существующие — это ломающее изменение, которое требует новой версии. Контракт — удобное место, где это видно: сравнение двух YAML сразу показывает, что изменилось. Подробный разбор — в статье Версионирование REST API.
Когда API-first избыточен
Подход не бесплатный: контракт нужно поддерживать, а генерация добавляет шаг в сборку. Для одноразового прототипа или крошечного внутреннего эндпоинта это перебор — там быстрее code-first. API-first окупается, когда API переживёт не один спринт, его потребляет больше одной команды или он публичный.
Глубже: проверка совместимости в сборке: сравнение спецификаций, линт и контрактные тестырасширенное
Вся эта фаза обещает, что контракт не сломается. Обещание ничего не стоит, пока его не проверяет сборка, и для этого есть три инструмента разного уровня.
Сравнение двух версий спецификации. Контракт лежит в репозитории, значит у сборки есть версия из главной ветки и версия из изменения. oasdiff breaking main/openapi.yaml openapi.yaml перечисляет ломающие изменения по правилам, которые в статье про версионирование описаны словами: удалённое или переименованное поле, поле ответа, ставшее необязательным, новый обязательный параметр, сужение перечисления, изменение типа. Сборка падает, если список не пуст и изменение не помечено как новая версия. Для Java есть openapi-diff, и результат тот же. Для protobuf в gRPC то же делает buf breaking --against '.git#branch=main': он ловит смену номера поля и типа, о которых статья про gRPC говорит как о главной ошибке.
Линт спецификации. Сравнение ловит поломку, линт ловит непоследовательность: operationId не задан, у операции нет описания 401, имя поля в snake_case посреди camelCase, параметр без maximum. Spectral с набором правил компании прогоняется на каждом изменении, и правила это те самые соглашения из статей раздела, записанные машинно: каждая закрытая операция описывает 401 и 403, у каждого списка есть size с максимумом, у каждой ошибки тело application/problem+json.
Контрактные тесты. Спецификация может быть безупречной, а сервер отвечать не по ней. Контрактный тест проверяет живой код против контракта. Со стороны поставщика: тест поднимает приложение и сверяет реальные ответы со схемой (валидатор OpenAPI как фильтр в тесте, springdoc с проверкой на соответствие). Со стороны потребителя: подход Pact, где потребитель записывает, какие запросы он делает и какие ответы ожидает (это и есть pact, контракт со стороны потребителя), публикует его в брокер, а сборка поставщика проигрывает все pact'ы своих потребителей против своего кода и падает, если чьи-то ожидания нарушены. Так поставщик узнаёт до выката, что поле, которое он «никому не нужным» удалил, читает мобильное приложение. Spring Cloud Contract делает похожее в обратную сторону: поставщик описывает контракты, из них генерируются тесты для него и заглушки для потребителей.
Порядок внедрения: сначала сравнение спецификаций, оно бесплатно и ловит самое дорогое; потом линт с пятью правилами, а не пятьюдесятью; контрактные тесты с потребителями заводят, когда потребителей больше одного и они не в вашей команде, до этого хватает проверки ответов против схемы в собственных тестах.
Коротко
- API-first = сначала контракт (OpenAPI), потом код по нему.
- Даёт параллельную работу фронта и бэкенда, единый источник правды, генерацию кода и моки из спецификации.
- Contract-first — источник правды YAML (генерируем код из него); code-first — источник правды код (генерируем описание из него).
- Contract-first хорош для публичных API и нескольких команд; code-first — для маленьких внутренних сервисов.
- Правки контракта — в YAML, не в сгенерированный код. Чтобы это соблюдалось само, сгенерированный код кладут в
build/generatedи не коммитят. - Для Spring Boot 3 генератору нужен
useSpringBoot3=true— иначе он выдаст импортыjavax.*и сборка упадёт. - Версии контракта: добавлять необязательное можно, удалять/переименовывать — ломающее изменение.
- Совместимость проверяет сборка, а не ревью:
oasdiff breakingиbuf breakingпротив главной ветки, Spectral с правилами компании, проверка ответов против схемы в тестах, Pact с потребителями, когда их больше одного.
Что почитать дальше
- OpenAPI: метаданные и типичные ошибки в REST — сам формат контракта: operationId, tags, параметры и частые ошибки.
- Версионирование REST API — как менять контракт, не ломая потребителей.
- URL и ресурсы REST — из чего складывается хорошо спроектированный контракт.