Documentation-Driven Development: как мы генерируем Go-код из OpenAPI-спецификаций
- пятница, 27 февраля 2026 г. в 00:00:13

Привет, Хабр. Я Матвей Лихота, старший Go-разработчик из МТС Web Services. По моему опыту, документация, которую пишут руками отдельно от кода, устаревает уже в момент следующего коммита. Из-за этого мы в команде тратили до 20% времени на поддержание актуальности swagger-документации в десятке микросервисов. И когда ошибки интеграции уже стали привычным фоном, мы все-таки решились и перевернули всё с ног на голову: внедрили Documentation-Driven Development (DDD) — подход в разработке, когда процесс начинается с документации.
Что за подход и что он дал в итоге, зачем понадобилась утилита oapi-codegen и как мы генерируем Go-код из OpenAPI-спецификаций — подробно рассказал и показал под катом.
Сначала немного теории. Самый распространенный сценарий создания документации — это code-first, где мы сначала пишем код, а описание API собираем потом. Обычно это происходит через комментарии к хендлерам и моделям, из которых генератор по типу swaggo собирает Swagger или OpenAPI. В этой модели документация становится побочным продуктом разработки.
Проблем при таком подходе множество. Во-первых, документация вторична: она устаревает в момент следующего коммита, если мы забудем (а мы забудем) изменить комментарии и перегенерировать спецификацию. Это приводит к рассогласованности API и его описания, а если сервисов больше 10, то поддержка превращается в боль.
В отличие от него, DDD (Documentation-Driven Development) — подход в разработке, когда процесс начинается с обсуждения и создания документации. В том числе с OpenAPI-спецификаций (если предполагается REST API), Proto-контрактов (если нужно использовать RPC) и им подобных. Иногда этот подход называют spec-first.
Ключевой момент в том, что при DDD документация становится условным контрактом, с которым работают так же строго, как с кодом:
согласовывают со всеми сторонами до написания кода;
статистически валидируют на предмет противоречий (spectral, swagger-cli);
выпускают релизы, используя семантическое версионирование (SemVer);
используют как единственный источник правды для разработчиков и пользователей API.
Разница между подходами хорошо видна на практике. С code-first легко начать, но дальше трудоемкость поддержки растет с увеличением количества эндпоинтов. Любая правка требует выполнения цепочки действий, и чем больше сервисов, тем больше таких циклов. При этом стоимость ошибки в документации растет вместе с количеством пользователей, так как это крайне негативно влияет на опыт взаимодей��твия с продуктом.
В spec-first сначала меняется openapi.yaml, затем генерируется код, и только после этого пишется или корректируется реализация. Обсуждаются только изменения легко читаемого контракта, а не уже написанного кода, который чаще всего понимает только пара разработчиков.

Кроме того, при spec-first ошибки обнаруживаются раньше. Когда вся команда смотрит на спецификацию до начала реализации, неудобные форматы, ненужные поля или неполные ответы замечаются на этапе code-review, а не за день до релиза. Притом, исправление в YAML занимает минуты и не требует переписывать готовую логику.
Из одной OpenAPI-спецификации можно получить серверные интерфейсы и модели, клиент на Go, актуальную документацию и моки для тестирования. Плюс и в том, что статическая проверка добавляет еще один уровень контроля: валидаторы находят несогласованные статусы, пропущенные поля или некорректные схемы еще до запуска сервиса.
И поддержка, конечно, становится проще, ведь когда меняется API, изменения сразу видны в спецификации. Новый разработчик открывает один файл и быстро понимает, какие эндпоинты есть и как они работают.
Но у spec-first похода тоже есть минусы. Самый главный из них — подход хорошо работает только в командах, где есть аналитики с архитекторами, ответственные за спецификации, или время на написание спецификаций разработчиками.
Для наглядности сравним оба подхода по нескольким критериям:

Так что, на мой взгляд, подход code-first тоже хорош, но подходит только для определенных задач:
Быстрых прототипов и MVP. На ранних стадиях развития проекта скорость итераций может быть важнее чистоты архитектуры, а пока вы будете проектировать идеальный openapi.yaml, конкуренты уже выпустят фичу.
API, единственным потребителем которого являетесь вы сами. Например, сервис для управления конфигурацией других сервисов, который используют два разработчика внутри команды. Писать для него спецификацию — оверхэд.
API с частыми изменениями требований. Пока идет цикл проектирования, требования не раз поменяются.
Legacy-проектов без ресурсов на рефакторинг. Когда сервисы работают и планов по их развитию нет, удобно, если все останется как есть.
Ну а мы покажем, как работаем со spec-first.
Переходить на spec-first мы решили постепенно: взяли один сервис, выбрали пару самых живых эндпоинтов и договорились, что любое изменение API сначала оформляется в спецификации, а уже потом в коде. Расскажу по шагам, как все было и что мы делали.
Для генерации мы решили использовать oapi-codegen — это CLI-утилита и библиотека для преобразования спецификаций OpenAPI в код Go. Она читает OpenAPI-спецификацию и умеет создавать серверные заглушки (поддерживает Chi, Echo, Gin), строгие структуры из схем и полноценные клиенты, используя шаблоны Go в формате text/template (расширение *.tmpl). Установка максимально простая:
# команда для установки инструмента v2.3.0 или выше go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest
После установки бинарник появился в $(go env GOPATH)/bin, а что директория добавлена в PATH мы убедились командой:
oapi-codegen --version
Команда отрабатывает — двигаемся дальше. По умолчанию oapi-codegen использует встроенные шаблоны в формате text/template (расширение *.tmpl). В большинстве случаев этого достаточно, но как только появляется желание изменить поведение генерации, приходится подключать собственные.
Кроме того, утилита позволяет указать директорию с шаблонами через флаг -templates. Важно, чтобы структура файлов внутри этой директории по��торяла структуру оригинальных шаблонов, иначе они просто не будут использованы, ошибки при этом не покажется.
В качестве примера покажу шаблон генерации клиента с запросами для разрешения коллизий с сервером при одновременной генерации (он еще пригодится):
// ClientWithResponses builds on ClientInterface to offer response payloads type ClientWithResponses struct { ClientInterface } // NewClientWithResponses creates a new ClientWithResponses, which wraps // Client with return type handling func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { client, err := NewClient(server, opts...) if err != nil { return nil, err } return &ClientWithResponses{client}, nil } {{$clientTypeName := opts.OutputOptions.ClientTypeName -}} // WithBaseURL overrides the baseURL. func WithBaseURL(baseURL string) ClientOption { return func(c *{{ $clientTypeName }}) error { newBaseURL, err := url.Parse(baseURL) if err != nil { return err } c.Server = newBaseURL.String() return nil } } // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { {{range . -}} {{$hasParams := .RequiresParamObject -}} {{$pathParams := .PathParams -}} {{$opid := .OperationId -}} // {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse request{{if .HasBody}} with any body{{end}} {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse(ctx context.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) {{range .Bodies}} {{if .IsSupportedByClient -}} {{$opid}}{{.Suffix}}WithResponse(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) {{end -}} {{end}}{{/* range .Bodies */}} {{end}}{{/* range . $opid := .OperationId */}} } {{range .}}{{$opid := .OperationId}}{{$op := .}} {{$responseTypeDefinitions := getResponseTypeDefinitions .}} type {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}} struct { Body []byte HTTPResponse *http.Response {{- range $responseTypeDefinitions}} {{.TypeName}} *{{.Schema.TypeDecl}} {{- end}} } {{- range $responseTypeDefinitions}} {{- range .AdditionalTypeDefinitions}} type {{.TypeName}} {{if .IsAlias }}={{end}} {{.Schema.TypeDecl}} {{- end}} {{- end}} // Status returns HTTPResponse.Status func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } return http.StatusText(0) } // StatusCode returns HTTPResponse.StatusCode func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } {{ if opts.OutputOptions.ClientResponseBytesFunction }} // Bytes is a convenience method to retrieve the raw bytes from the HTTP response func (r {{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}) Bytes() []byte { return r.Body } {{end}} {{end}} {{range .}} {{$opid := .OperationId -}} {{/* Generate client methods (with responses)*/}} // {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse request{{if .HasBody}} with arbitrary body{{end}} returning *{{genResponseTypeName $opid}} func (c *ClientWithResponses) {{$opid}}{{if .HasBody}}WithBody{{end}}WithResponse(ctx context.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error){ rsp, err := c.{{$opid}}{{if .HasBody}}WithBody{{end}}(ctx{{genParamNames .PathParams}}{{if .RequiresParamObject}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}, reqEditors...) if err != nil { return nil, err } return Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp) } {{$hasParams := .RequiresParamObject -}} {{$pathParams := .PathParams -}} {{$bodyRequired := .BodyRequired -}} {{range .Bodies}} {{if .IsSupportedByClient -}} func (c *ClientWithResponses) {{(printf "Client%s" $opid)}}{{.Suffix}}WithResponse(ctx context.Context{{genParamArgs $pathParams}}{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) { rsp, err := c.{{$opid}}{{.Suffix}}(ctx{{genParamNames $pathParams}}{{if $hasParams}}, params{{end}}, body, reqEditors...) if err != nil { return nil, err } return Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp) } {{end}} {{end}} {{end}}{{/* operations */}} {{/* Generate parse functions for responses*/}} {{range .}}{{$opid := .OperationId}} // Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}} parses an HTTP response from a {{$opid}}WithResponse call func Parse{{genResponseTypeName (printf "Client%s" $opid) | ucFirst}}(rsp *http.Response) (*{{genResponseTypeName (printf "Client%s" $opid)}}, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } response := {{genResponsePayload (printf "Client%s" $opid)}} {{genResponseUnmarshal .}} return response, nil } {{end}}{{/* range . $opid := .OperationId */}}
И для примера шаблон для удобной генерации констант enum’ов:
{{- if gt (len .SecuritySchemeProviderNames) 0 }} const ( {{range $ProviderName := .SecuritySchemeProviderNames}} {{- $ProviderName | ucFirst}}Scopes = "{{$ProviderName}}.Scopes" {{end}} ) {{end}}
{{range .Types}} // {{ with .Schema.Description }}{{ . }}{{ else }}{{.TypeName}} defines model for {{.JsonName}}.{{ end }} {{- if eq .Schema.TypeDecl "openapi_types.UUID" }} type {{.TypeName}} = {{.Schema.TypeDecl}} {{- else }} type {{.TypeName}} {{.Schema.TypeDecl}} {{- end }} {{- if gt (len .Schema.EnumValues) 0 }} // List of {{ .TypeName }} const ( {{- $typeName := .TypeName }} {{- range $key, $value := .Schema.EnumValues }} {{ $typeName }}{{ $key }} {{ $typeName }} = "{{ $value }}" {{- end }} ) {{- end }} {{end}}
Всё это можно использовать для кастомизации вывода генератора oapi-codegen.
Теперь нам нужно было получить openapi.yaml, который можно ревьюить. Если вы тоже выносите контракты в отдельный репозиторий или хотя бы в отдельную директорию, первым делом инициализируйте Go-модуль в корне. Это важно, чтобы сгенерированный код нормально собирался и мог импортироваться из сервисов.
Для этого в корне папки contracts мы выполнили:
go mod init contracts
После этого появился go.mod (если спецификаций несколько и они лежат в одном репозитории, одного модуля достаточно). Дальше создали структуру для спецификаций. Нам удобно было держать их отдельно от кода сервисов, в одном месте с общими схемами, поэтому получилось так:

После этого начали писать спецификацию сервиса и common.yaml с общими типами ошибок и другими компонентами. В целом, выделять что-то в отдельный файл не обязательно. Но, по опыту, рано или поздно это понадобится.
Мы придерживались нескольких важных правил:
у каждого метода должен быть свой operationId — он напрямую влияет на имена сгенерированных методов в Go, поэтому к нему стоит относиться внимательно;
ошибки и общие форматы ответов «с ошибкой» выносили в common.yaml, чтобы формат был единым для всех сервисов;
несмотря на одинаковые тела успешных ответов, мы не переносили их в common.yaml — запросы и успешные ответы имеют свойство меняться в зависимости от требований.
Кроме того, важно читать и обсуждать спецификацию до того, как кто-то начнет писать реализацию. Именно здесь всплывают ненужные поля, неудобные форматы со структурами и не интуитивно понятные константы — исправить их в YAML намного проще, чем переписывать код.
Итак, у нас был openapi.yaml и установленный oapi-codegen, теперь нужно было получить серверный каркас. Тут мы особенно обращали внимание на две вещи: типы и интерфейс хендлеров, чтобы реализация всегда шла по контракту.
openapi: 3.0.0 info: title: Phone Book API version: 1.0.0 paths: /abonents: put: summary: Creates an Abonent operationId: createAbonent tags: - abonent parameters: - $ref: "#/components/parameters/IdempotencyKey" required: true requestBody: $ref: "#/components/requestBodies/CreateAbonentRequest" responses: "201": $ref: "#/components/responses/GetAbonentResponse" "400": $ref: "../common.yaml#/components/responses/ApiErrorResponse" "500": $ref: "../common.yaml#/components/responses/ApiErrorResponse" default: $ref: "../common.yaml#/components/responses/ApiErrorResponse" get: summary: Retrieve a list of Abonents operationId: GetAbonentsList tags: - abonent parameters: - $ref: "#/components/parameters/PageToken" - $ref: "#/components/parameters/Limit" responses: '200': $ref: "#/components/responses/GetAbonentsListResponse" '400': $ref: "../common.yaml#/components/responses/ApiErrorResponse" default: $ref: "../common.yaml#/components/responses/ApiErrorResponse" /abonent/{id}: get: summary: Retrieves specific Abonent operationId: getAbonent tags: - abonent parameters: - name: id in: path description: Identifier of Abonent (uuid) required: true schema: type: string format: uuid responses: "200": $ref: "#/components/responses/GetAbonentResponse" "400": $ref: "../common.yaml#/components/responses/ApiErrorResponse" "404": $ref: "../common.yaml#/components/responses/ApiErrorResponse" "500": $ref: "../common.yaml#/components/responses/ApiErrorResponse" default: $ref: "../common.yaml#/components/responses/ApiErrorResponse" put: summary: Replaces Abonent information per ID operationId: replaceAbonent tags: - abonent parameters: - name: id in: path description: Identifier of Abonent (uuid) required: true schema: type: string format: uuid requestBody: $ref: "#/components/requestBodies/ReplaceAbonentRequest" responses: "200": $ref: "#/components/responses/GetAbonentResponse" "400": $ref: "../common.yaml#/components/responses/ApiErrorResponse" "403": $ref: "../common.yaml#/components/responses/ApiErrorResponse" "500": $ref: "../common.yaml#/components/responses/ApiErrorResponse" default: $ref: "../common.yaml#/components/responses/ApiErrorResponse" components: parameters: PageToken: name: pageToken in: query schema: type: string Limit: name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } IdempotencyKey: name: Idempotency-Key in: header schema: type: string format: uuid description: UUID для идемпотентных мутаций requestBodies: CreateAbonentRequest: description: Information for creating Abonent required: true content: application/json: schema: type: object properties: AbonentInfo: $ref: "#/components/schemas/AbonentInfo" ReplaceAbonentRequest: description: Information for replacing Abonent required: true content: application/json: schema: properties: AbonentInfo: $ref: "#/components/schemas/AbonentInfo" schemas: Abonent: type: object properties: id: type: string format: uuid description: type: string type: $ref: "#/components/schemas/AbonentType" trusted: description: Is abonent trusted type: boolean AbonentInfo: type: object properties: description: type: string type: $ref: "#/components/schemas/AbonentType" trusted: description: Is abonent trusted type: boolean AbonentType: type: string enum: - organization # организация - scammer # мошенник - advertising # реклама - personal # физическое лицо responses: GetAbonentsListResponse: description: OK content: application/json: schema: type: array items: $ref: '#/components/schemas/Abonent' GetAbonentResponse: description: OK content: application/json: schema: $ref: '#/components/schemas/Abonent'
openapi: "3.0.3" info: title: Common OpenAPI definitions version: "1.0" description: Common entities contact: name: Best MWS development team url: https://confluence.mws.ru/display/Development/Best+Team email: matwey.likhota@yandex.ru components: responses: ApiErrorResponse: description: General error response content: application/problem+json: schema: $ref: "#/components/schemas/ApiError" schemas: ApiErrorCode: type: string enum: - bad_request # 400 - unauthorized # 401 - forbidden # 403 - not_found # 404 - method_not_allowed # 405 - not_acceptable # 406 - request_timeout # 408 - state_conflict # 409 - not_implemented # 501 - request_cancelled # 499 - internal # 500 ApiError: description: Описание ошибки type: object properties: error: $ref: "#/components/schemas/BaseError" required: - error BaseError: type: object properties: code: $ref: "#/components/schemas/ApiErrorCode" message: type: string description: Описание ошибки params: $ref: "#/components/schemas/ApiErrorParams" required: - code - message ApiErrorParams: type: object description: Параметры ошибки properties: validations: description: Информации о валидации полей type: array items: $ref: "#/components/schemas/ValidationInfo" additional_info: description: Дополнительная информация о ошибке type: array items: type: string ValidationInfo: description: Информация о валидации поля type: object properties: field: type: string message: type: string required: - field - message
Для этого мы создали файл конфигурации утилиты oapi-codegen — oapi-codegen.yaml — в папке со спецификацией сервиса. Содержимое получилось таким:
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi codegen/oapi-codegen/HEAD/configuration-schema.json package: phoneBook output: openapi/phoneBook/openapi.gen.go generate: echo-server: true models: true
Затем запустили генерацию из корня репозитория контрактов:
oapi-codegen -package openapi -generate types,skip-prune -o openapi/common.gen.go openapi/common.yaml
В нашей спецификации были ссылки на общий файл ../common.yaml, поэтому мы должны обращаться к нему как к импортируемому модулю, для этого нужно добавить ключ import-mapping со значением [путь к спецификации, на которую ссылаемся: ссылка на пакет, который сгенерируется]:
oapi-codegen -config ./openapi/phoneBook/oapi-codegen.yaml -import mapping=../common.yaml:contracts/openapi ./openapi/phoneBook/openapi.yaml
После этого в папке openapi появился файл common.gen.go, в папке openapi/phoneBook — файл openapi.gen.go с реализацией echo-роутера. К слову, для генерации gin, chi, gorilla/mux достаточно изменить oapi-codegen.yaml — вот подробное описание ключей.
Самое главное, что на этом этапе у нас сгенерировались интерфейс серверных методов и функция регистрации роутов:
// ServerInterface represents all server handlers. type ServerInterface interface { // Retrieves specific Abonent // (GET /abonent/{id}) GetAbonent(ctx echo.Context, id openapi_types.UUID) error // Replaces Abonent information per ID // Replaces Abonent information per ID // (PUT /abonent/{id}) ReplaceAbonent(ctx echo.Context, id openapi_types.UUID) error // Retrieve a list of Abonents // Retrieve a list of Abonents // (GET /abonents) GetAbonentsList(ctx echo.Context, params GetAbonentsListParams) error // Creates an Abonent // Creates an Abonent // (PUT /abonents) CreateAbonent(ctx echo.Context, params CreateAbonentParams) error } type EchoRouter interface { CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route } // RegisterHandlers adds each server route to the EchoRouter. func RegisterHandlers(router EchoRouter, si ServerInterface) { RegisterHandlersWithBaseURL(router, si, "") }
После генерации мы получили интерфейс ServerInterface и функцию RegisterHandlers. Интерфейс требует от реализации совпадать со спецификацией, а функция автоматически регистрирует все пути.
Дальше создали структуру сервера, реализовали методы и подключили роуты — покажу на примере минимального каркаса на Echo. Пути импорта можно подправить под свою структуру, а остальное — скопировать как есть:
package phoneBook import ( "context" "net/http" "github.com/labstack/echo/v4" "yourproject/internal/api" // сгенерированный пакет ) type HttpServer struct { base *echo.Echo usc Usecase // интерфейс юзкейса } // Реализуем эти сгенерированные методы func (s *HttpServer) GetAbonent(ctx echo.Context, id openapi_types.UUID) error { // TODO: implement me panic(“implement me”) } func (s *HttpServer) ReplaceAbonent(ctx echo.Context, id openapi_types.UUID) error { // TODO: implement me panic(“implement me”) } func (s *HttpServer) GetAbonentsList(ctx echo.Context, params GetAbonentsListParams) error { // TODO: implement me panic(“implement me”) } func (s *HttpServer) CreateAbonent(ctx echo.Context, params CreateAbonentParams) error { // TODO: implement me panic(“implement me”) } func StartServer(usc Usecase) err { e := echo.New() // здесь может быть любая обертка над echo сервером server := &HttpServer{ base: e, usc: usc, } // Магия: регистрируем все роуты из спецификации api.RegisterHandlers(e, server) e.Logger.Fatal(server.base.Start(":8080")) }
На этом моменте уже можно увидеть киллер-фичу подхода: если меняешь контракт, то меняется и код. Компилятор первым сообщает, что именно надо поправить в реализации.
Сервер мы сгенерировали, теперь изменим файл oapi-codegen.yaml для генерации кл��ента:
# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json package: phoneBook output: openapi/phoneBook/openapi.gen.go generate: echo-server: true models: true client: true
При генерации той же командой получаем ошибку GetAbonentResponse redeclared in this block. А произошло это из-за ошибки в дефолтных шаблонах генерации клиента и сервера.
Чтобы решить проблему, вернулись на первый шаг. Положили нужный шаблон в contracts/openapi/templates/client-with-responses.tmpl и запустили генерацию с указанием папки:
oapi-codegen -config ./openapi/phoneBook/oapi-codegen.yaml -import mapping=../common.yaml:contracts/openapi -templates "./openapi/templates/" ./openapi/phoneBook/openapi.yaml
Теперь в том же файле, помимо сервера, сгенерирован еще и готовый к использованию клиент:
// Client which conforms to the OpenAPI3 specification for this service. type Client struct { // The endpoint of the server conforming to this interface, with scheme, // https://api.deepmap.com for example. This can contain a path relative // to the server, such as https://api.deepmap.com/dev-test, and all the // paths in the swagger spec will be appended to the server. Server string // Doer for performing requests, typically a *http.Client with any // customized settings, such as certificate chains. Client HttpRequestDoer // A list of callbacks for modifying requests which are generated before sending over // the network. RequestEditors []RequestEditorFn } // ClientWithResponses builds on ClientInterface to offer response payloads type ClientWithResponses struct { ClientInterface } // NewClientWithResponses creates a new ClientWithResponses, which wraps // Client with return type handling func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { client, err := NewClient(server, opts...) if err != nil { return nil, err } return &ClientWithResponses{client}, nil } // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { // GetAbonentWithResponse request GetAbonentWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*ClientGetAbonentResponse, error) // ReplaceAbonentWithBodyWithResponse request with any body ReplaceAbonentWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClientReplaceAbonentResponse, error) ReplaceAbonentWithResponse(ctx context.Context, id openapi_types.UUID, body ReplaceAbonentJSONRequestBody, reqEditors ...RequestEditorFn) (*ClientReplaceAbonentResponse, error) // GetAbonentsListWithResponse request GetAbonentsListWithResponse(ctx context.Context, params *GetAbonentsListParams, reqEditors ...RequestEditorFn) (*ClientGetAbonentsListResponse, error) // CreateAbonentWithBodyWithResponse request with any body CreateAbonentWithBodyWithResponse(ctx context.Context, params *CreateAbonentParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClientCreateAbonentResponse, error) CreateAbonentWithResponse(ctx context.Context, params *CreateAbonentParams, body CreateAbonentJSONRequestBody, reqEditors ...RequestEditorFn) (*ClientCreateAbonentResponse, error) }
Клиент полностью соответствует документации. Если в openapi.yaml меняется структура ответа или сигнатура функции, компилятор покажет, где код больше не совпадает со спецификацией.
В обычной модели интеграции такие ошибки часто обнаруживаются уже на тестах или в рантайме. Здесь мы можем ловить их на этапе сборки. Вот пример использования:
import "contracts/openapi/phoneBook" func main() { c := phoneBook.NewClientWithResponses("http://api:8080") resp, err := c.GetAbonentsListWithResponse(context.Background(), params) // resp — это уже сгенерированный тип ClientGetAbonentsListResponse }
Если генерацию запускать руками, спецификация и код снова начнут расходиться. Поэтому следующим логичным для нас шагом было — сделать так, чтобы код генерировался автоматически.
Мы пошли простым путем. Добавили один скрипт, который проходит по всем openapi.yaml в репозитории и запускает oapi-codegen с нужными флагами:

У каждой спецификации свой oapi-codegen.yaml. Наполнение файла скрипта для генерации примерно такое:
#!/bin/bash echo "Generate golang code from OpenAPI specs" templates="./openapi/templates/" oapi-codegen -package openapi -generate types,skip-prune -templates "${templates}" -o openapi/common.gen.go openapi/common.yaml; spec_file_mask="*/openapi.yaml" spec_files=$(find ./openapi -type f -path "${spec_file_mask}") for opapi_file_rel_path in ${spec_files}; do directory=$(dirname "${opapi_file_rel_path}") local_config="${directory}/oapi-codegen.yaml" if [ ! -f $local_config ] then >&2 echo "OpenAPI code generation failed for '${opapi_file_rel_path}':" >&2 echo " Script requires local config '${local_config}' in the same directory!" exit -1 else >&2 echo "Processing '${directory}':" oapi-codegen \ -config ${local_config} \ -templates ${templates} \ -import-mapping=../common.yaml:contracts/openapi \ "${opapi_file_rel_path}" >&2 echo "Done!" fi done
Чтобы генерация была воспроизводимой, мы зафиксировали версию oapi-codegen в Docker-образе.
ARG GO_VERSION=1.24.11 FROM golang:${GO_VERSION}-bookworm AS base RUN apt update \ && apt install -y zip dos2unix WORKDIR /work ARG OAPI_CODEGEN_VERSION=2.5.1 RUN go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v${OAPI_CODEGEN_VERSION} ENV PATH=$PATH:/work/bin COPY ./deployment/golang/oapi-generate-all.sh . RUN dos2unix oapi-generate-all.sh && chmod +x oapi-generate-all.sh ENTRYPOINT [ "oapi-codegen" ]
Запуск сервиса oapi-gen-go из docker-compose.yml будет генерировать код для всех openapi-спецификаций репозитория контрактов, что удобно внедрить в пайплайн.
version: '3.7' services: oapi-gen-go: image: oapi-gen-go:latest container_name: oapi-gen-go build: context: ../.. dockerfile: ./deployment/golang/oapi.Dockerfile args: - GO_VERSION=1.24.11 - OAPI_CODEGEN_VERSION=2.5.1 volumes: - ../../openapi:/work/openapi entrypoint: [ "/work/oapi-generate-all.sh" ]
После подключения CI-процесс стал автоматическим: спецификация меняется — код перегенерируется — ошибки находятся автоматически. При желании, перед этапом генерации можно валидировать openapi-контракт утилитами spectral и/или swagger-cli.
После того, как сервер и клиент начинают генерироваться из спецификации, вылезает следующая типовая боль — валидация входных данных. Сначала их немного, потом в каждом хендлере появляются одинаковые проверки. body != nil, длины строк, диапазоны чисел и обязательные поля. Код быстро обрастает if’ами, часть проверок делается по привычке, а часть вовсе забывается.
Вообще, удобная валидация — это еще одна киллер-фича DDD-подхода, так как все ограничения можно реализовать в репозитории контрактов, а код приложения при этом будет максимально чистым.
Для этого мы дописали шаблоны генерации и включили валидацию, которую oapi-codegen генерирует, в данном случае, для Echo.
1. echo-interface.tmpl:
// ServerInterface represents all server handlers. type ServerInterface interface { {{range .}}{{$opid := .OperationId}}{{.SummaryAsComment }} // ({{.Method}} {{.Path}}) {{.OperationId}}(httpCtx echo.Context{{genParamArgs .PathParams}}{{if .RequiresParamObject}}, params *{{.OperationId}}Params{{end}}{{if .Bodies}}{{$hasJSON := false}}{{$hasForm := false}}{{range .Bodies}}{{if or (eq .ContentType "application/json") (eq .ContentType "application/json-patch+json")}}{{$hasJSON = true}}{{end}}{{if eq .ContentType "application/x-www-form-urlencoded"}}{{$hasForm = true}}{{end}}{{end}}{{if $hasJSON}}, body *{{$opid}}JSONRequestBody{{else if $hasForm}}, body *{{$opid}}FormdataRequestBody{{else}}, body *{{$opid}}JSONRequestBody{{end}}{{end}}) error {{end}} }
2. echo-wrappers.tmpl:
// ServerInterfaceWrapper converts echo contexts to parameters. type ServerInterfaceWrapper struct { Handler ServerInterface } {{range .}}{{$opid := .OperationId}}// {{$opid}} converts echo context to params. func (w *ServerInterfaceWrapper) {{.OperationId}} (httpCtx echo.Context) error { var err error {{range .PathParams}}// ------------- Path parameter "{{.ParamName}}" ------------- var {{$varName := .GoVariableName}}{{$varName}} {{.TypeDef}} {{if .IsPassThrough}} {{$varName}} = httpCtx.Param("{{.ParamName}}") {{end}} {{if .IsJson}} err = json.Unmarshal([]byte(httpCtx.Param("{{.ParamName}}")), &{{$varName}}) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON")) } {{end}} {{if .IsStyled}} err = runtime.BindStyledParameterWithLocation("{{.Style}}",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationPath, httpCtx.Param("{{.ParamName}}"), &{{$varName}}) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err)) } {{end}} {{end}} {{if .RequiresParamObject}} // Parameter object where we will unmarshal all parameters from the context var params {{.OperationId}}Params {{range $paramIdx, $param := .QueryParams}}// ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- {{if .IsStyled}} err = runtime.BindQueryParameter("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", httpCtx.QueryParams(), ¶ms.{{.GoName}}) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err)) } {{else}} if paramValue := httpCtx.QueryParam("{{.ParamName}}"); paramValue != "" { {{if .IsPassThrough}} params.{{.GoName}} = {{if not .Required}}&{{end}}paramValue {{end}} {{if .IsJson}} var value {{.TypeDef}} err = json.Unmarshal([]byte(paramValue), &value) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON")) } params.{{.GoName}} = {{if not .Required}}&{{end}}value {{end}} }{{if .Required}} else { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("query argument '{{.ParamName}}' is required, but not found")) }{{end}} {{end}} {{end}} {{if .HeaderParams}} headers := httpCtx.Request().Header {{range .HeaderParams}}// ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- if valueList, found := headers[http.CanonicalHeaderKey("{{.ParamName}}")]; found { var {{.GoName}} {{.TypeDef}} n := len(valueList) if n != 1 { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("expected one value for '{{.ParamName}}', got %d", n)) } {{if .IsPassThrough}} params.{{.GoName}} = {{if not .Required}}&{{end}}valueList[0] {{end}} {{if .IsJson}} err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON")) } {{end}} {{if .IsStyled}} err = runtime.BindStyledParameterWithLocation("{{.Style}}",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationHeader, valueList[0], &{{.GoName}}) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err)) } {{end}} params.{{.GoName}} = {{if not .Required}}&{{end}}{{.GoName}} } {{if .Required}}else { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("header parameter '{{.ParamName}}' is required, but not found")) }{{end}} {{end}} {{end}} {{range .CookieParams}} if cookie, err := httpCtx.Cookie("{{.ParamName}}"); err == nil { {{if .IsPassThrough}} params.{{.GoName}} = {{if not .Required}}&{{end}}cookie.Value {{end}} {{if .IsJson}} var value {{.TypeDef}} var decoded string decoded, err := url.QueryUnescape(cookie.Value) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unescaping cookie parameter '{{.ParamName}}'")) } err = json.Unmarshal([]byte(decoded), &value) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("failed unmarshaling parameter '{{.ParamName}}' as JSON")) } params.{{.GoName}} = {{if not .Required}}&{{end}}value {{end}} {{if .IsStyled}} var value {{.TypeDef}} err = runtime.BindStyledParameterWithLocation("simple",{{.Explode}}, "{{.ParamName}}", runtime.ParamLocationCookie, cookie.Value, &value) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid format for parameter '{{.ParamName}}': %w", err)) } params.{{.GoName}} = {{if not .Required}}&{{end}}value {{end}} }{{if .Required}} else { return echo.NewHTTPError(http.StatusBadRequest, fmt.Errorf("query argument '{{.ParamName}}' is required, but not found")) }{{end}} {{end}}{{/* .CookieParams */}} // Validate parsed params if err = httpCtx.Validate(params); err != nil { return err } {{end}}{{/* .RequiresParamObject */}} {{if .Bodies}} {{$hasJSON := false}}{{$hasForm := false}}{{range .Bodies}}{{if or (eq .ContentType "application/json") (eq .ContentType "application/json-patch+json")}}{{$hasJSON = true}}{{end}}{{if eq .ContentType "application/x-www-form-urlencoded"}}{{$hasForm = true}}{{end}}{{end}} // Parse and validate request body {{if and $hasJSON $hasForm}}// Supports both application/json and application/x-www-form-urlencoded body := &{{$opid}}JSONRequestBody{} contentType := httpCtx.Request().Header.Get("Content-Type") if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") { formBody := &{{$opid}}FormdataRequestBody{} if err = httpCtx.Bind(formBody); err != nil { return err } if err = httpCtx.Validate(formBody); err != nil { return err } // Convert form body to JSON body type *body = {{$opid}}JSONRequestBody(*formBody) } else { if err = httpCtx.Bind(body); err != nil { return err } if err = httpCtx.Validate(body); err != nil { return err } } {{else if $hasForm}}body := &{{$opid}}FormdataRequestBody{} if err = httpCtx.Bind(body); err != nil { return err } if err = httpCtx.Validate(body); err != nil { return err } {{else}}body := &{{$opid}}JSONRequestBody{} if err = httpCtx.Bind(body); err != nil { return err } if err = httpCtx.Validate(body); err != nil { return err } {{end}} {{end}} // Invoke the callback with all the unmarshalled arguments err = w.Handler.{{.OperationId}}(httpCtx{{genParamNames .PathParams}}{{if .RequiresParamObject}}, ¶ms{{end}}{{if .Bodies}}, body{{end}}) return err } {{end}}
Шаблоны положили в contracts/openapi/templates/echo. Используя их, oapi-codegen сгенерирует валидацию параметров и тела запроса на входе.
Теперь для автоматической валидации параметров осталось просто реализовать метод Validate() error у всех типов, которые мы использовали для параметров и тел запросов. Мне нравится библиотека ozzo-validation, поэтому использовал ее. А в качестве примера — вот реализации методов Validate() для сущностей сервера из практикума:
package phoneBook import ( "regexp" validation "github.com/go-ozzo/ozzo-validation/v4" ) const ( MinLimit = 1 MaxLimit = 200 ) const ( uuidRegexp = "^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$" ) /* RULE SETS */ var ValidationUuidRules = []validation.Rule{ validation.Required, validation.Length(36, 36), validation.Match(regexp.MustCompile(uuidRegexp)), } var ValidationLimitRules = []validation.Rule{ validation.Required, validation.Min(MinLimit), validation.Max(MaxLimit), } var ValidationPageTokenRules = []validation.Rule{ validation.Required, validation.Length(1, 200), } /* BASE TYPES VALIDATION */ func (t *AbonentInfo) Validate() error { return validation.ValidateStruct(&t, validation.Field(&t.Description, validation.Required, validation.Length(1, 100)), validation.Field(&t.Trusted, validation.Required), validation.Field(&t.Type, validation.Required, validation.In(AbonentTypeAdvertising, AbonentTypeOrganization, AbonentTypePersonal, AbonentTypeScammer)), ) } /* REQUEST PARAMS VALIDATION */ func (p *GetAbonentsListParams) Validate() error { return validation.ValidateStruct(&p, validation.Field(&p.Limit, validation.NilOrNotEmpty, validation.When(p.Limit != nil, ValidationLimitRules...)), validation.Field(&p.PageToken, validation.NilOrNotEmpty, validation.When(p.PageToken != nil, ValidationPageTokenRules...)), ) } func (p *CreateAbonentParams) Validate() error { return validation.ValidateStruct(&p, validation.Field(&p.IdempotencyKey, ValidationUuidRules...), ) } /* REQUEST BODIES VALIDATION */ func (b *CreateAbonentJSONRequestBody) Validate() error { return b.AbonentInfo.Validate() } func (b *ReplaceAbonentJSONRequestBody) Validate() error { return b.AbonentInfo.Validate() }
В итоге код хендлеров становится чистым — в них остается только то, ради чего и существует эндпоинт. А вся рутина по проверкам ушла в один слой, где ее проще поддерживать и проще тестировать.
Если решите повторить все шаги и команды выше, то на выходе у вас уже будет базовый набор — репозиторий со спецификациями OpenAPI, генерация кода, которую можно запускать одной командой, и понятное место, где лежат шаблоны, если нужно подкрутить результат под свои задачи. Весь получившийся код можно найти на GitHub.
Ну а если вдруг захотите внедрить это в легаси, моя рекомендация — не пытайтесь переписать все сразу:
начните с одного нового сервиса по DDD, при этом в коде лучше создавать второй отдельный сервер с использованием сгенерированного кода;
оставьте как есть существующие эндпоинты старого сервера, а новые тестируйте интеграционными тестами;
мигрируйте только тогда, когда все протестировано.
По нашим подсчетам, DDD-подход окупился за несколько месяцев: согласование нового эндпоинта сократилось с одного–двух дней переписки и встреч до ревью одного yaml-файла. К слову, самый большой сервис мы согласовывали примерно два часа.
Исчезли инциденты из-за несовместимости API, и высвободилось около 20% времени разработчиков, которое мы теперь используем для создания бизнес-логики, вместо поддержания документации.
Если тоже так хотите — выбирайте эндпоинт и начинайте с первого шага. Но будьте готовы: после перехода на DDD работать по-старому вы уже не захотите.
А как вы сейчас пишете документацию и сколько времени на это уходит?