API-first means writing the OpenAPI spec before writing any code. It sounds like extra process. In practice, it’s one of the highest-leverage habits a backend team can adopt — because it shifts the hardest conversations (what the API actually does) to the cheapest phase of development.
Why API-first exists
The default workflow: engineer writes a controller, thinks about the request/response shape, commits code, generates docs from code. Docs and contract are a side-effect of implementation.
Problems with that workflow:
- API design is ad-hoc — whatever the implementer typed is what ships
- Consumers have no contract until the backend is done — frontend and mobile wait
- Breaking changes sneak through — no diff against a known contract
- Documentation is an afterthought — often stale, always thin
API-first inverts it: write the spec, review the spec, generate the scaffolding from it, then fill in the logic.
The workflow
- Write the OpenAPI YAML/JSON spec — paths, parameters, request/response schemas, status codes, examples
- Review with the team — backend, frontend, QA, whoever calls the API
- Generate server stubs and client SDKs from the spec
- Fill in the server implementation — the compiler forces you to match the contract
- CI validates that the implementation still matches the spec
Changes to the API start with a spec change and a PR review. The spec is the single source of truth.
What a good spec looks like
A minimum example for an orders endpoint:
openapi: 3.0.3
info:
title: Orders API
version: 1.2.0
paths:
/orders:
post:
summary: Place a new order
operationId: placeOrder
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PlaceOrderRequest' }
responses:
'201':
description: Order created
content:
application/json:
schema: { $ref: '#/components/schemas/Order' }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
components:
schemas:
PlaceOrderRequest:
type: object
required: [customerId, items]
properties:
customerId: { type: string, format: uuid }
items:
type: array
items: { $ref: '#/components/schemas/OrderItem' }
OrderItem:
type: object
required: [sku, quantity]
properties:
sku: { type: string }
quantity: { type: integer, minimum: 1 }
Order:
type: object
required: [id, status, createdAt]
properties:
id: { type: string, format: uuid }
status: { type: string, enum: [PENDING, PAID, SHIPPED, CANCELLED] }
createdAt: { type: string, format: date-time }
Error:
type: object
required: [code, message]
properties:
code: { type: string }
message: { type: string }That’s enough to generate typed clients in any language, validate requests at the edge, and render readable docs.
Code generation for Spring Boot
The openapi-generator-maven-plugin can generate either controllers (you implement) or full server stubs:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.0.1</version>
<executions>
<execution>
<goals><goal>generate</goal></goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/openapi.yaml</inputSpec>
<generatorName>spring</generatorName>
<apiPackage>com.company.api</apiPackage>
<modelPackage>com.company.api.model</modelPackage>
<configOptions>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<useTags>true</useTags>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>This generates interfaces like OrdersApi with typed method signatures. You implement:
@RestController
public class OrderController implements OrdersApi {
private final OrderService service;
@Override
public ResponseEntity<Order> placeOrder(PlaceOrderRequest req) {
Order order = service.place(req);
return ResponseEntity.status(CREATED).body(order);
}
}The compiler enforces that your implementation matches the spec. If the spec changes, your code fails to build until you update.
Linting the spec in CI
A spec that compiles isn’t necessarily a good one. Use a linter in CI:
# Using spectral
npx @stoplight/spectral lint src/main/resources/openapi.yamlDefault rulesets catch: inconsistent naming, missing examples, undocumented error responses, vague descriptions. Custom rules can enforce organization conventions (e.g. “all endpoints must have operationId”, “all dates use ISO 8601”).
Breaking change detection
The underrated superpower: diffing specs. Tools like openapi-diff or oasdiff can classify changes as breaking or non-breaking:
Breaking changes:
- Removed response '200' from POST /orders
- Required property 'items' removed from PlaceOrderRequest
Non-breaking:
- Added optional property 'notes' to PlaceOrderRequestWire this into your CI. A breaking-change PR requires explicit approval; non-breaking ships normally.
The objections, and why they don’t hold
“Writing specs slows us down.” It feels slower on day one. By month two, the conversation about “what does the API actually return when the user doesn’t exist?” has happened once instead of four times. Net speed is higher.
“The spec drifts from the code.” Only if you don’t generate code from the spec. When the spec is the source of truth and code is generated from it, drift is impossible.
“Our API is internal, we don’t need docs.” Internal consumers benefit more than external ones — they make assumptions you don’t catch. A spec makes assumptions explicit.
“OpenAPI is ugly YAML.” It is. Use tooling (Stoplight, Swagger Editor, Redocly) to edit visually. Or write it once, generate, forget.
Benefits that show up later
- Contract tests — tools like
pactorschemathesisgenerate test cases from the spec; they catch bugs humans would miss - Mock servers — frontend can develop against a mock generated from the spec while backend is still in progress
- Client SDKs — generated Java, TypeScript, Swift, Kotlin clients eliminate entire categories of integration bugs
- Rendered documentation — Redoc / Swagger UI generate docs that stay current by definition
Start small
You don’t need to convert every existing API at once. Start with:
- New endpoints — write spec first, even if it’s 10 lines
- Major refactors — freeze the current spec, design the new one, migrate
- Services with external consumers — spec-first has the highest payoff here
Six months later, most of the team will forget what it was like before.
Closing thought
API-first isn’t an architecture decision. It’s a workflow decision. The architecture it enables — cleaner boundaries, better documentation, fewer integration bugs — is just the side effect of forcing one conversation to happen at the cheapest possible moment: before anyone writes code.