← назад к разделу

Обычно API рождается из кода: написали контроллеры, а описание (если оно есть) сгенерировали потом. API-first переворачивает порядок: сначала договариваемся о контракте, и только потом пишем код по нему. Разберём, зачем так и как это устроено.

Разница между двумя порядками видна сразу, если разложить работу по тактам; такт здесь это одна итерация команды, обычно неделя или две.

code-first: сначала код, описание потом 1 2 3 4 5 6 бэкенд бэкенд пишет код фронтенд ждёт 3 такта фронт пишет экраны 6 тактов contract-first: сначала контракт, код по нему 1 2 3 4 5 6 контракт OpenAPI бэкенд бэкенд по контракту фронтенд фронт по моку 4 такта бэкенда и фронта столько же; фронт стартует с такта 2, а не с 4: срок 6 → 4

Бэкенд и фронт пишут столько же — по три такта. Разница в том, что при 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 проще и его достаточно.

openapi.yaml DTO и интерфейсы генератор для сервера клиент-SDK генератор для потребителя мок-сервер prism для фронтенда Swagger UI документация для всех

Из одного файла контракта получают четыре вещи, и каждая достаётся своей стороне: серверу интерфейсы, потребителю клиент, фронтенду мок, читателю документация.

Как выглядит процесс contract-first

  1. Пишем/правим OpenAPI-контракт — эндпоинты, схемы, ошибки, версии.
  2. Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
  3. Генерация — openapi-generator создаёт DTO + интерфейсы контроллеров для сервера и клиент для потребителя.
  4. Реализация — контроллер реализует сгенерированный интерфейс; логика пишется внутри, контракт не переписывается руками.
  5. Проверка в 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 с потребителями, когда их больше одного.

Что почитать дальше