Skip to content
gohacks.dev
APIs

OpenAPI Contracts Shared Between Go Services and Clients

Keep one OpenAPI file as the contract between Go services and their clients, generate both ends from it, and fail CI when an implementation drifts from it.

12 min read
Summarize in AI
Flat illustration of a gateway connected to a ring of services, with the OpenAPI Initiative logo.

Two teams, or one team and a mobile app, agree on an API. Someone writes it down in a wiki. The server is built, the client is built, and three months later the client crashes on a field the server stopped sending in a refactor nobody thought was breaking. The wiki page was never wrong at the time it was written. It just had no way to stay right.

Tested with Go 1.25.6, kin-openapi v0.133.0 and gorilla/mux v1.8.1, macOS on Apple Silicon (arm64)

A shared contract fixes that by making the description executable. The OpenAPI document is the single source both sides are checked against: the server must answer in the shape it promises, the client must send what it allows, and a machine tells you when either side moves. This article covers how to organise that in a Go codebase, from where the file lives through generation on both ends to the test that notices drift. The generation steps build on oapi-codegen and generated clients, and the first article in this series, writing the OpenAPI document itself, is worth reading first if you have not. Everything ran on Go 1.25.6.

Contract first, or code first

There are two ways to get an OpenAPI file.

Code first: write handlers, add annotations or use a framework that reflects the types, and generate the document. The server is the source of truth. It is easy to start and it keeps the document current by construction, but the contract is whatever the code happened to do, and a refactor changes the contract without anyone choosing to. The code first frameworks article compares the Go options.

Contract first: write the document by hand, review it like code, and generate the Go server interface and clients from it. The contract is a decision, and the code has to catch up to it.

When the API has consumers outside your team, contract first is the one that holds up. The reason is who is allowed to change what. In code first, a server engineer can change a response without realising anyone depends on it. In contract first, the change is a diff on a YAML file that an owner of the client has been asked to review.

That is the difference, and it is organisational more than technical. If you are one developer with one web frontend in the same repository, code first is perfectly fine and less work. This article assumes you are not.

Where the file lives

The contract has to be reachable from every repository that consumes it, with a version. Three arrangements work:

  1. A dedicated repository holding openapi.yaml and a CI pipeline. Servers and clients depend on a tagged release. Changes need a pull request there first. It is clean and a little heavy.
  2. The server's repository, tagged, with clients pinning a tag or commit. Cheapest, and fine while one team owns the API.
  3. A Go module that embeds the document with go:embed and exports it. Other Go services import the module and get the exact bytes at the version in their go.mod. TypeScript or Swift clients still need a copy, which a CI step can publish as an artifact.

Whatever you pick, avoid copying the file by hand between repositories. A copy is the first step toward two contracts that say different things.

Versioning follows the same rules as the API itself: additive changes are minor, removals and meaning changes are major. The API versioning article covers what counts as which, and it is worth putting the rules in the repository's README so a reviewer does not decide them by memory.

What the document has to say precisely

A loose contract validates everything and protects nothing. The properties that matter most:

  • required lists. Which fields are guaranteed present. A field that is not required can be absent, and clients must code for that.
  • additionalProperties: false on response objects you own. It makes a new field a visible event. It sounds strict, and for responses it is what lets you spot a handler that started serialising an internal column. For request bodies it also gives you strict decoding, as the DisallowUnknownFields article explains.
  • Formats and patterns. format: date-time, minLength, maxLength, a pattern on identifiers. These make the validators useful, and they document limits that otherwise live only in code.
  • Every status code you can return. Including the error responses. An undocumented status is a contract violation, and a client generator cannot build a type for it.
  • A shared error schema. Reference one Problem schema everywhere, matching your problem details format.

Nullable fields deserve care. Go's JSON handling cannot tell an absent field from an explicit null without help, and the contract should say which one it means. See null versus omitted.

Generating both ends

From the document you generate a server interface and one or more clients.

On the Go server, oapi-codegen produces an interface with one method per operation, typed request and response structs, and router glue for the standard library mux, chi, Echo or Gin. You implement the interface. If the contract adds an operation, the build fails until you implement it, which is the point: the compiler now tells you the server is behind the contract.

On the client side, the same document drives generated Go clients and, for the web frontend, TypeScript types through a generator such as openapi-typescript. A shared contract is also the clean way to get TypeScript types from Go without reflecting over structs.

Commit generated code or generate in CI, but pick one and enforce it. A common rule is: generated files are committed, and CI regenerates and fails if git diff shows a difference. That catches the person who edited the YAML and forgot to run the generator.

Validating against the contract at runtime and in tests

Generated code covers the shape of types. It does not catch a handler that returns a title of 200 characters when the contract says 80, or one that emits an extra field because a struct gained a column. For that, validate real traffic against the document.

The lab uses kin-openapi, which loads and validates a spec, finds the route for a request, and checks both the request and the response against it. The contract it checks is a small Notes API:

openapi.yamlYAML
openapi: 3.0.3
info:
  title: Notes API
  version: 1.0.0
paths:
  /notes/{id}:
    get:
      operationId: getNote
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^n_[0-9]+$' }
      responses:
        '200':
          description: The note
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Note' }
        '404':
          description: No such note
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Problem' }
components:
  schemas:
    Note:
      type: object
      required: [id, title, created_at]
      additionalProperties: false
      properties:
        id: { type: string }
        title: { type: string, minLength: 1, maxLength: 80 }
        created_at: { type: string, format: date-time }
    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }

A few things in this document are doing real work. additionalProperties: false on Note. A pattern on the path parameter. A 404 response referencing Problem. And no other statuses are listed.

The test program loads the spec, validates the spec itself, builds a router and replays a request through the implementation, then validates the response. The handler has a drift switch so you can see each kind of failure:

main.goGo
package main

import (
	"bytes"
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"

	"github.com/getkin/kin-openapi/openapi3"
	"github.com/getkin/kin-openapi/openapi3filter"
	"github.com/getkin/kin-openapi/routers/gorillamux"
)

//go:embed openapi.yaml
var spec []byte

// handler is the implementation under test. Its "bug" mode drifts from the contract on purpose.
func handler(drift string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		switch drift {
		case "extra field":
			json.NewEncoder(w).Encode(map[string]any{"id": "n_1", "title": "Hello", "created_at": "2026-01-01T10:00:00Z", "owner_email": "[email protected]"})
		case "missing field":
			json.NewEncoder(w).Encode(map[string]any{"id": "n_1", "title": "Hello"})
		case "wrong type":
			json.NewEncoder(w).Encode(map[string]any{"id": 1, "title": "Hello", "created_at": "2026-01-01T10:00:00Z"})
		case "undocumented status":
			w.WriteHeader(http.StatusTeapot)
			w.Write([]byte(`{}`))
		default:
			json.NewEncoder(w).Encode(map[string]any{"id": "n_1", "title": "Hello", "created_at": "2026-01-01T10:00:00Z"})
		}
	})
}

func main() {
	doc, err := openapi3.NewLoader().LoadFromData(spec)
	if err != nil {
		panic(err)
	}
	if err := doc.Validate(context.Background()); err != nil {
		panic(err)
	}
	router, err := gorillamux.NewRouter(doc)
	if err != nil {
		panic(err)
	}

	// check replays one request through the implementation and validates the response against the spec.
	check := func(label, drift, path string) {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		route, params, err := router.FindRoute(req)
		if err != nil {
			fmt.Printf("%-22s route: %v\n", label, err)
			return
		}
		in := &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route}
		if err := openapi3filter.ValidateRequest(context.Background(), in); err != nil {
			fmt.Printf("%-22s request rejected by contract\n", label)
			return
		}
		rec := httptest.NewRecorder()
		handler(drift).ServeHTTP(rec, req)
		body, _ := io.ReadAll(rec.Result().Body)
		out := &openapi3filter.ResponseValidationInput{RequestValidationInput: in, Status: rec.Code,
			Header: rec.Header(), Options: &openapi3filter.Options{IncludeResponseStatus: true}}
		out.SetBodyBytes(bytes.TrimSpace(body))
		if err := openapi3filter.ValidateResponse(context.Background(), out); err != nil {
			msg, _, _ := strings.Cut(err.Error(), "\n")
			fmt.Printf("%-22s FAIL %s\n", label, msg)
			return
		}
		fmt.Printf("%-22s ok\n", label)
	}

	check("matches the spec", "", "/notes/n_1")
	check("extra field", "extra field", "/notes/n_1")
	check("missing field", "missing field", "/notes/n_1")
	check("wrong type", "wrong type", "/notes/n_1")
	check("undocumented status", "undocumented status", "/notes/n_1")
	check("bad id in the path", "", "/notes/abc")
}
Text
matches the spec       ok
extra field            FAIL response body doesn't match schema #/components/schemas/Note: property "owner_email" is unsupported
missing field          FAIL response body doesn't match schema #/components/schemas/Note: Error at "/created_at": property "created_at" is missing
wrong type             FAIL response body doesn't match schema #/components/schemas/Note: Error at "/id": value must be a string
undocumented status    FAIL status is not supported
bad id in the path     request rejected by contract

What the run shows

The honest handler passes. Each of the four drifts fails, and each message names the cause:

  • The extra field is reported as property "owner_email" is unsupported. This is the case that matters for security: a developer adds a column to a struct, the JSON now includes it, and nothing in a unit test notices because nothing asserted its absence.
  • The missing field names created_at as missing, which matches the required list.
  • The wrong type is caught at /id, since the contract says string and the handler sent a number.
  • The undocumented status fails with status is not supported. A 418 is a silly example, but a surprising 409 or 429 is exactly the response a client has no branch for.

The last line shows the other direction. A request for /notes/abc never reaches the handler because the path parameter pattern ^n_[0-9]+$ does not match. In production, the same request validation can sit in middleware and return a 400 before any handler code runs. Be cautious doing that for responses, though: validating every response in production costs CPU, and a failing response check has nothing useful to return to the client. Run response validation in tests and in a staging soak, and sample it in production if at all.

Putting it in the pipeline

A contract only helps if CI uses it. A workable pipeline, in order:

  1. Lint the document. Rules for naming, required descriptions, error responses on every operation. Tools such as Spectral do this.
  2. Detect breaking changes between the proposed document and the one on the main branch. Tools like oasdiff compare two OpenAPI files and classify changes: removed endpoint, new required request field, narrowed enum. I am not running those tools in this article, and their documentation is where the exact categories live. The CI step fails on a breaking change unless the version bump says it is intended.
  3. Regenerate and diff. Fail if generated code is stale.
  4. Run the contract tests like the lab above, against the real handlers with a real database, so the responses are the ones production would produce.
  5. Publish the document as a versioned artifact for client teams, and optionally publish rendered documentation.

The consumer side can run the same trick in reverse: before calling a real service, a client test can validate its outgoing requests against the contract with a mock server generated from it. When the expectations come from the clients rather than from the spec, the technique is called consumer driven contract testing, covered in the Pact article. The two complement each other. OpenAPI says what the provider promises; consumer contracts say which parts of the promise each client actually relies on, which is what lets you know that removing a rarely used field is safe.

Governance without a committee

The part that fails most often is not technical. A shared contract needs an owner and a small, boring process:

  • One team owns each document and is listed in the file header.
  • Changes arrive as pull requests that tag a reviewer from each consuming team.
  • Additive changes need one approval; breaking changes need an explicit deprecation plan, with a date, as in deprecating endpoints.
  • The document's version changes with every release, and the server exposes it, for example in a response header or a /version route, so you can tell which contract a running instance claims to implement.

None of this needs a committee. It needs one rule everybody follows: nothing changes at runtime that has not changed in the document first.

Running two contract versions side by side

Breaking changes happen eventually, and then the server has to speak both the old and the new contract for a while. Keep one document per major version, v1.yaml and v2.yaml, each with its own generated package, and mount both under their prefixes. The handlers for v2 can share domain code with v1; only the translation layer at the edge differs.

The contract tests then run once per document. When v1 reaches its sunset date, deleting v1.yaml deletes its generated code, its tests and its routes together, and the compiler shows what else depended on them. That is a much smaller job than untangling a single document that has grown conditionals for four generations of clients.

Limits of the approach

OpenAPI describes shapes and status codes well, and behaviour badly. It cannot say that a created note appears in the list within a second, that deleting twice is safe, or that a field is ignored when another is set. Those rules belong in the descriptions, in tests, and in the narrative documentation. Treat the contract as the floor of what is guaranteed, not the whole of it.

It also describes JSON over HTTP. For gRPC the equivalent contract is the protobuf schema, with its own tooling for compatibility, and a service that exposes both has to keep them in agreement. Generating one from the other, as gRPC gateway does, is one way to have a single source.

Finally, strict validation has a cost. A contract that is updated late, or tools that disagree about edge cases of OpenAPI 3.1 versus 3.0, produce failures that waste time. Start with the checks that catch real bugs, the ones in this article's lab, and add more when they would have caught something. The specification itself is at the OpenAPI Initiative, and the kin-openapi repository documents the validation functions used here.

Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.