OpenAPI and contracts

Generating the spec from code versus writing it first, springdoc, breaking versus compatible changes, and contract tests.

5 min read🔗 REST API Engineering

An API without a written contract has one anyway — it is whatever the code does today, discovered by every client through trial and a 400. OpenAPI is the format for writing it down, and the interesting decisions are which direction it flows (from the code, or into it), what a change to it is allowed to do, and how to find out that a change broke someone before they do.

Code-first or design-first

Code-first generates the document from the controllers. Add springdoc-openapi and the running application serves /v3/api-docs (JSON) and /swagger-ui.html, built from @RequestMapping, the DTOs' types, Bean Validation constraints and whatever @Operation/@Schema annotations you add. It is the cheapest way to have a spec, it is never out of date, and it has a weakness that is the whole argument for the alternative: the spec is a consequence. Nobody reviewed the contract before the code existed; whatever the controller happened to return became the API.

Design-first writes openapi.yaml by hand (or in a design tool), reviews it as a pull request with the consumers, and then generates the server interfaces and the client SDKs from it with openapi-generator. The controller implements OrdersApi; a change to the spec is a compile error in the server until it is implemented, and a new client in another language is one generator run. The cost is a generator in the build and a discipline: the yaml is the source of truth, and edits to the generated interfaces are lost.

The honest split: design-first for an API with external consumers or several teams, where the contract is negotiated; code-first for an internal service with one or two callers you sit next to, where the review happens in the code. Many teams do code-first and commit the generated spec, which gets most of the review benefit — a spec diff in the pull request — without the generator.

springdoc, and making the document honest

The generated document is only as good as what the code declares. Three things springdoc cannot infer and the document is wrong without:

java
@Operation(summary = "Place an order", description = "Idempotent under the Idempotency-Key header for 24 hours.")
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Created", headers = @Header(name = "Location")),
    @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
    @ApiResponse(responseCode = "409", description = "A different order was already placed under this key"),
})
@PostMapping("/orders")
ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest body, @RequestHeader("Idempotency-Key") UUID key) { ... }

The error responses, because springdoc documents the success path and a client needs the 400 and 409 bodies more; the headers, in and out; and examples, which are worth more to a client than the schema. Bean Validation constraints do flow through — @Size(max = 200) becomes maxLength: 200 — which is one more reason to put validation on the DTO from the validation lesson. Group the endpoints by tag, version the info.version, and treat the /v3/api-docs output as an artifact: commit it, or publish it from the build, so it exists when the service is down.

Compatible and breaking changes

The versioning lesson listed what breaks a client; the spec makes the list checkable. On a response, adding a field is compatible (clients ignore what they do not know — and the Jackson lesson's "unknown fields: strict or lenient" is where a client gets that wrong), removing or renaming one breaks, changing a type breaks, and making a nullable field non-null is safe while the reverse is not. On a request, adding an optional field is compatible, adding a required one breaks, tightening validation breaks, and loosening it is safe. Enum values: adding one to a request is safe, adding one to a response breaks every client with an exhaustive switch. Status codes: a new 4xx for a case that used to 200 is a break, however correct it is.

Put the check in the pipeline: openapi-diff (or oasdiff) compares the spec on main with the spec on the branch and fails on a breaking change, with the list. That one job is the cheapest contract test there is, and it catches the rename that the author thought was internal.

Consumer-driven contracts

A spec diff knows what changed; it does not know who reads what. A consumer-driven contract does: each consumer records the requests it makes and the response fields it uses, and the provider's build replays those against the real service. The testing course covers Pact in detail — the pact file, the broker, the @State methods — and the trade-off is the same one it states: worth the machinery when there are consumers you do not control, and an OpenAPI diff otherwise. The two compose: the spec is the contract everyone can read, the pacts are the subset each consumer depends on, and a change that passes both is a change you can ship.

Publishing the spec

A spec nobody can find is a spec nobody follows. Serve it from the running service under a path the gateway exposes (with the same authentication as the API, not less — the spec names every endpoint); publish the rendered documentation from the build to a developer portal or a static site; and version it beside the API, so /v2/openapi.yaml describes /v2. The documentation should answer three questions a client has before writing code — how do I authenticate, what does an error look like, and what changed since last month — and a generated spec answers only the middle one unless you write the other two into info.description.

Progress is saved on this device and to your account when signed in.