Обычно API рождается из кода: написали обработчики, а описание (если оно есть) собрали потом из комментариев. API-first переворачивает порядок: сначала договариваемся о контракте, и только потом пишем код по нему. В Go этот путь особенно естественный: генератор даёт интерфейс, компилятор не даст его реализовать не так, а сгенерированный код по обычаю лежит в репозитории и виден на ревью.
Что такое API-first простыми словами
API-first — подход, при котором контракт API проектируется первым, до реализации, и становится главным артефактом, по которому работают все стороны. Контракт — машиночитаемое описание: какие есть маршруты, какие параметры и тела запросов, какие ответы и коды ошибок. Стандартный формат для REST — OpenAPI (YAML или JSON).
Короткая формула: сначала контракт, потом код. Контракт — договор между теми, кто API предоставляет, и теми, кто его потребляет.
Противоположность — code-first: пишем обработчики, а описание получаем из них. В Go это комментарии swag или библиотеки вроде huma, которые строят схему из структур и обработчиков. Оба варианта дают на выходе OpenAPI-документ, но порядок и источник правды разные.
Зачем это нужно
Фронтенд ждёт три недели, пока бэкенд допишет маршрут, а потом ещё неделю переделывает под то, что получилось не так, как договаривались на словах. Контракт, готовый раньше кода, убирает обе недели: фронт поднимает мок по спецификации и пишет интерфейс, пока бэкенд реализует логику.
Контракт один и машиночитаемый, поэтому не бывает «в коде одно, в документации другое»: документация, модели, интерфейс сервера и клиент генерируются из него же. Дизайн обсуждают на ревью YAML до первой строчки реализации, а поправить YAML дешевле, чем переписывать готовый код. И контракт сравнивают между версиями автоматически, ловя ломающие изменения в сборке.
Contract-first и code-first
Оба пути ведут к OpenAPI-документу, но по-разному.
Contract-first — источник правды это OpenAPI YAML. Сначала пишем спецификацию руками, затем oapi-codegen создаёт из неё модели, интерфейс сервера и клиента. Обработчики реализуют сгенерированный интерфейс, а ограничения из схемы — required, minLength, maximum — проверяет middleware по той же спецификации.
# фрагмент контракта: контракт первичен
paths:
/orders/{id}:
get:
operationId: getOrder
tags: [orders]
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 — источник правды это код. swag читает комментарии над обработчиками (// @Param id path string true "...") и собирает swagger.json; huma и fuego идут дальше и строят схему из типов запроса и ответа без комментариев, заодно проверяя входные данные. Быстрее на старте, но контракт здесь — следствие кода, а не договор, и комментарии swag расходятся с кодом при первом же рефакторинге, потому что компилятор их не читает.
Что выбрать: для публичного API и для нескольких команд, которым нужно договориться заранее, обычно лучше contract-first. Для небольшого внутреннего сервиса, который пишет одна команда, code-first на huma проще и его достаточно.
Как выглядит процесс contract-first
- Пишем/правим OpenAPI-контракт — маршруты, схемы, ошибки, версии.
- Ревью контракта — обсуждаем дизайн на уровне YAML, до кода.
- Генерация —
oapi-codegenсоздаёт модели, интерфейс сервера и клиента; результат коммитится. - Реализация — тип реализует сгенерированный интерфейс; логика пишется внутри, сгенерированный файл не правится руками.
- Проверка в сборке — линт контракта, сравнение с предыдущей версией, свежесть сгенерированного кода.
Важное правило команды: правки контракта идут в YAML, а не в сгенерированный код.
Как это собирается: oapi-codegen
Описание лежит в репозитории сервиса, рядом с кодом, который его реализует: api/openapi.yaml. Генератор настраивается файлом, а не флагами, чтобы настройки были видны на ревью:
# api/oapi-codegen.yaml
package: api
output: internal/api/gen.go
generate:
chi-server: true
strict-server: true
models: true
embedded-spec: true
Запуск — через go generate, чтобы команда была одна и та же у всех:
//go:generate go tool oapi-codegen -config ../../api/oapi-codegen.yaml ../../api/openapi.yaml
package api
С Go 1.24 генератор подключают как инструмент модуля (go get -tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen): версия зафиксирована в go.mod, и у всех разработчиков и в сборке генерируется одно и то же. До 1.24 то же делали через go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.4.1.
Четыре настройки, которые здесь решают. chi-server генерирует регистрацию маршрутов под chi и разбор параметров пути, запроса и заголовков. strict-server добавляет типизированный интерфейс: вместо (w, r) метод получает структуру запроса с разобранным телом и возвращает структуру ответа — писать json.NewDecoder и WriteHeader руками больше не нужно. models даёт структуры схем с тегами json. embedded-spec встраивает сам контракт в бинарник, откуда его берёт валидатор запросов.
Как выглядит реализация. Сгенерированный интерфейс описывает подписи, типы и коды; ваш тип его реализует:
type OrdersServer struct {
create *usecase.CreateOrder
orders *query.Orders
}
func (s *OrdersServer) GetOrder(ctx context.Context, req api.GetOrderRequestObject) (api.GetOrderResponseObject, error) {
o, err := s.orders.ByID(ctx, req.Id)
if errors.Is(err, order.ErrNotFound) {
return api.GetOrder404ApplicationProblemPlusJSONResponse{Code: ptr("ORDER_NOT_FOUND")}, nil
}
if err != nil {
return nil, err
}
return api.GetOrder200JSONResponse(toAPI(o)), nil
}
func (s *OrdersServer) CreateOrder(ctx context.Context, req api.CreateOrderRequestObject) (api.CreateOrderResponseObject, error) {
id, err := s.create.Handle(ctx, toCommand(req.Body), req.Params.IdempotencyKey)
if err != nil {
return nil, err
}
return api.CreateOrder201JSONResponse(toAPI(s.orders.MustByID(ctx, id))), nil
}
spec, _ := api.GetSwagger()
spec.Servers = nil
r := chi.NewRouter()
r.Use(nethttpmiddleware.OapiRequestValidator(spec))
api.HandlerFromMux(api.NewStrictHandler(&OrdersServer{...}, nil), r)
Главное свойство этой схемы: подпись метода нельзя изменить, не изменив описание. Добавили параметр в обработчик — код не компилируется, пока параметр не появился в контракте. На каждый код ответа из контракта есть свой тип (GetOrder200JSONResponse, GetOrder404ApplicationProblemPlusJSONResponse), и вернуть код, которого в контракте нет, нельзя физически. Это и есть та дисциплина, ради которой всё затевалось; при генерации из кода она отсутствует.
Валидация по схеме. Сам oapi-codegen ограничения из схемы не проверяет: minLength и minimum в сгенерированной структуре никак не отражены. Проверяет их OapiRequestValidator из nethttp-middleware: он берёт встроенный контракт и сверяет каждый запрос — формат uuid в пути, обязательный заголовок Idempotency-Key, qty не меньше единицы — до того, как запрос дойдёт до обработчика. Строка spec.Servers = nil нужна, потому что иначе валидатор сверяет ещё и адрес сервера из контракта и отвергает запросы на localhost.
У валидатора есть своя форма ответа на ошибку, и она текстовая. Чтобы нарушения приходили в вашем формате Problem Details, валидатору передают Options{ErrorHandler: …}, который пишет через тот же httperr.Write, что и всё приложение, — об этом статья про единый обработчик ошибок.
Клиент из того же описания. Добавив в настройки client: true, получают типизированного клиента с теми же моделями: client.GetOrderWithResponse(ctx, id) возвращает структуру с разобранными JSON200 и ApplicationproblemJSON404. Потребитель на Go не пишет обращения руками, а меняется клиент вместе с контрактом.
Первая ловушка: сгенерированный код коммитят
В Go сгенерированный код лежит в репозитории, и это отличается от привычки других стеков, где его прячут в каталог сборки. Причина проста: go build не запускает go generate, и потребитель модуля не обязан иметь генератор. Отсюда два следствия.
Первое: правка руками в gen.go живёт, пока кто-то не перегенерирует, — и обычно это происходит через месяц, в чужой ветке. Файл помечен строкой // Code generated by ... DO NOT EDIT., линтеры и ревьюеры её видят, но полагаться на это нельзя.
Второе, которое и решает первое: сборка проверяет свежесть сгенерированного кода.
- run: go generate ./...
- run: git diff --exit-code -- internal/api/gen.go
Изменили контракт и забыли перегенерировать — сборка красная. Поправили gen.go руками — сборка красная. Правило «правки только в YAML» держится не на договорённости, а на двух строках конвейера.
Вторая ловушка: контракт и реализация разъезжаются
Главная беда contract-first не в том, что его трудно начать, а в том, что через полгода описание отличается от поведения. В Go два места из трёх закрыты генератором, но третье остаётся.
Поля появляются мимо описания. Сгенерированная модель ответа содержит ровно поля схемы, и лишнее поле туда не попадёт без правки контракта. Но если обработчик возвращает свою структуру через обычный json.Encoder, минуя сгенерированные типы ответа, расхождение возвращается. Строгий сервер это закрывает: вернуть можно только тип ответа из контракта.
Коды ответов не совпадают. Тип на каждый код решает вопрос для успешных путей. Остаётся единый обработчик ошибок: он отдаёт 409 и 422, о которых контракт может не знать. Ловится тестом, который сверяет ответы с контрактом: openapi3filter.ValidateResponse из kin-openapi в интеграционных тестах падает на коде или поле, которого в описании нет.
Примеры врут. Пример в описании собран руками год назад. Лечится проверкой примеров по схеме — spectral это умеет — или генерацией примеров из снимков реальных ответов в тестах.
Отсюда практическое правило: contract-first без проверки ответов по схеме превращается в code-first с лишним файлом. Описание должно быть проверяемым, иначе оно документация, а не контракт.
Чем проверяют контракт
spectral — линтер описания: у каждой операции есть operationId, описание и пример, имена в одном стиле, коды ответов перечислены, ошибки описаны единой схемой. Свои правила добавляют файлом настроек; запускают в сборке до генерации.
oasdiff — сравнение двух описаний, и он сам написан на Go. Различает совместимые изменения и ломающие: убрали поле из ответа, добавили обязательный параметр, сузили тип, убрали значение перечисления. В сборке сравнивают описание ветки с описанием основной ветки:
go run github.com/oasdiff/oasdiff@latest breaking --fail-on ERR main/api/openapi.yaml api/openapi.yaml
Проверка ответов по схеме. Линт и сравнение проверяют описание, а не поведение. openapi3filter из kin-openapi (та же библиотека, на которой работает валидатор запросов) проверяет и ответы: в тесте через httptest ответ сервера сверяется со схемой, и лишнее поле, не тот тип или код, которого нет в контракте, роняют тест.
Моки для потребителя. prism mock api/openapi.yaml поднимает сервер, отвечающий примерами из описания, — фронтенд начинает работу, не дожидаясь реализации. Оговорка: мок отвечает примерами, поэтому качество примеров становится частью работы.
Кто владеет контрактом и где он лежит
В репозитории сервиса (обычный выбор). Описание меняется вместе с реализацией, ревью одно. Потребитель получает его как часть модуля: Go-клиент генерируется в отдельный пакет pkg/ordersclient, и соседний сервис подключает его обычным go get с версией из тега.
В отдельном репозитории схем (для организации с десятками сервисов). Все контракты в одном месте, общие правила линта, общие схемы ошибок. Минус — изменение контракта требует изменения в двух репозиториях.
Кто владеет: команда сервиса, а не команда потребителя. Потребитель участвует в обсуждении до принятия, и это оформляют как ревью изменения описания.
Чем платят за contract-first
Ревью описания. Каждое изменение API требует ревью ещё одного файла, и обсуждение формы занимает время до начала работы. Это и есть главная выгода (обсудить до кода) и главная цена (медленнее старт).
Конфликты при слиянии. Описание на несколько тысяч строк, в которое одновременно пишут пять человек. Лечится разбиением на файлы по тегам с $ref между ними — oapi-codegen и oasdiff умеют читать такие.
Генератор неудобен на краях. Загрузка файлов (multipart), потоковые ответы, oneOf с discriminator — всё это oapi-codegen описывает беднее, чем хотелось бы: oneOf превращается в json.RawMessage с методами AsXxx, потоки приходится отдавать обычным обработчиком. Обычный выход — описать маршрут в контракте, а реализовать его руками, вне строгого интерфейса. Если таких краёв много, смотрят на ogen: он строже следует спецификации, генерирует валидацию и oneOf с типами, но жёстче к самому контракту и не на всякий YAML соглашается.
Дисциплина против скорости. Прототип, который выкидывают через две недели, contract-first только замедляет. Контракт нужен там, где по ту сторону другая команда или внешний клиент; там, где обе стороны в одном репозитории и выкатываются вместе, он избыточен.
Компромисс: описание из кода, проверяемое как контракт
Распространённый средний путь: сервис пишется на huma или с комментариями swag, описание генерируется из кода, но затем публикуется и проверяется как контракт — линт, oasdiff против предыдущей версии, запрет ломающих изменений в сборке.
Что это даёт: скорость code-first и главную гарантию contract-first (ломающее изменение не проходит незамеченным). Чего не даёт: обсуждения формы до реализации. Для внутренних сервисов этого обычно достаточно; для публичного API и контрактов между командами остаётся полноценный contract-first.
Контракт в работе: моки, версии, границы
Моки и параллельная работа. Главный практический выигрыш — мок прямо из контракта: Prism поднимает сервер по OpenAPI-файлу, и потребители начинают интеграцию, не дожидаясь готового бэкенда. Когда реальный сервис готов, переключаются на него — контракт тот же.
Версионирование и эволюция. Добавлять необязательные поля можно, удалять или переименовывать существующие — ломающее изменение, которое требует новой версии. Контракт — удобное место, где это видно: oasdiff показывает разницу двух YAML сразу. Подробный разбор — в статье про версионирование.
Когда API-first избыточен. Контракт нужно поддерживать, генерация добавляет шаг. Для одноразового прототипа или крошечного внутреннего маршрута это перебор. API-first окупается, когда API переживёт не один спринт, его потребляет больше одной команды или он публичный.
Глубже: проверка совместимости в сборкерасширенное
Обещание «контракт не сломается» ничего не стоит, пока его не проверяет сборка. Три инструмента разного уровня, и в Go-проекте все три живут в одном конвейере.
Сравнение двух версий. oasdiff breaking против главной ветки перечисляет ломающие изменения по правилам, которые статья про версионирование описывает словами. Сборка падает, если список не пуст и изменение не помечено как новая версия. Для gRPC то же делает buf breaking --against '.git#branch=main'.
Линт. Spectral с набором правил компании прогоняется на каждом изменении: каждая закрытая операция описывает 401 и 403, у каждого списка есть size с максимумом, у каждой ошибки тело application/problem+json. Правила — те самые соглашения из статей раздела, записанные машинно.
Контрактные тесты. Со стороны поставщика — openapi3filter в собственных тестах. Со стороны потребителя — Pact (pact-go): потребитель записывает, какие запросы делает и какие ответы ожидает, публикует контракт в брокер, а сборка поставщика проигрывает контракты всех потребителей против своего кода. Так поставщик узнаёт до выката, что поле, которое он «никому не нужным» удалил, читает мобильное приложение.
Порядок внедрения: сначала сравнение спецификаций — оно бесплатно и ловит самое дорогое; потом линт с пятью правилами, а не пятьюдесятью; контрактные тесты с потребителями — когда потребителей больше одного и они не в вашей команде.
Коротко
- API-first = сначала контракт (OpenAPI), потом код по нему; contract-first — источник правды YAML, code-first — код (
swag,huma). oapi-codegenсchi-server,strict-server,modelsиembedded-specдаёт интерфейс, который компилятор не даст реализовать не по контракту: на каждый код ответа свой тип.- Ограничения схемы проверяет не генератор, а
OapiRequestValidatorпо встроенному контракту;spec.Servers = nil, иначе валидатор отвергнетlocalhost; ошибки валидатора — через свойErrorHandlerв формате приложения. - Сгенерированный код в Go коммитят, а свежесть проверяет сборка:
go generate ./...иgit diff --exit-code. - Генератор как инструмент модуля (
go get -tool, Go 1.24+) фиксирует версию вgo.mod; запуск через//go:generate. - Расхождение контракта и кода ловят проверкой ответов по схеме через
openapi3filter; без неё contract-first — code-first с лишним файлом. - Совместимость проверяет сборка:
oasdiff breakingпротив главной ветки, Spectral с правилами компании,pact-goс потребителями, когда их больше одного. oneOf,multipartи потоки у генератора бедные: описать в контракте, реализовать руками или посмотреть наogen.- Контракт нужен там, где по ту сторону другая команда или внешний клиент; внутри одной команды хватает code-first с проверкой
oasdiff.
Что почитать дальше
- OpenAPI: метаданные и типичные ошибки в REST на Go — сам формат контракта:
operationId, теги, параметры. - Версионирование REST API на Go — как менять контракт, не ломая потребителей.
- URL и ресурсы REST на Go — из чего складывается хорошо спроектированный контракт.
- Единый обработчик ошибок в Go — куда подключить ошибки валидатора, чтобы формат был один.