Design-First APIs With OpenAPI — Schema as Single Source of Truth
Advertisement
Introduction
Code-first APIs lead to inconsistent documentation, forgotten edge cases, and surprise breaking changes. Design-first flips the workflow: write the OpenAPI spec first, then generate code, validation, and mocks from that single source of truth. Breaking changes fail CI before reaching production.
OpenAPI 3.1 Spec Structure
An OpenAPI spec defines your entire API contract in YAML or JSON. Every endpoint, request, response, error, and data type lives in one place.
# openapi.yaml
openapi: 3.1.0
info:
title: Orders API
description: Manage orders and checkout
version: 1.0.0
contact:
name: API Support
url: https://api.example.com/support
servers:
- url: https://api.example.com
description: Production
- url: https://staging-api.example.com
description: Staging
paths:
/orders:
get:
summary: List orders for authenticated user
operationId: listOrders
tags:
- orders
parameters:
- name: limit
in: query
description: Number of results to return
schema:
type: integer
default: 20
maximum: 100
- name: status
in: query
description: Filter by order status
schema:
type: string
enum: [pending, confirmed, shipped, delivered]
responses:
'200':
description: List of orders
content:
application/json:
schema:
$ref: '#/components/schemas/OrderListResponse'
'401':
$ref: '#/components/responses/UnauthorizedError'
'500':
$ref: '#/components/responses/InternalServerError'
security:
- bearerAuth: []
post:
summary: Create a new order
operationId: createOrder
tags:
- orders
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'400':
$ref: '#/components/responses/ValidationError'
'401':
$ref: '#/components/responses/UnauthorizedError'
security:
- bearerAuth: []
/orders/{orderId}:
get:
summary: Get order details
operationId: getOrder
tags:
- orders
parameters:
- name: orderId
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: Order details
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
$ref: '#/components/responses/NotFoundError'
security:
- bearerAuth: []
/payment-intents:
post:
summary: Create payment intent
operationId: createPaymentIntent
tags:
- payments
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreatePaymentIntentRequest'
responses:
'201':
description: Payment intent created
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentIntent'
'400':
$ref: '#/components/responses/ValidationError'
components:
schemas:
Order:
type: object
required: [id, userId, status, total, items, createdAt]
properties:
id:
type: string
format: uuid
userId:
type: string
format: uuid
status:
type: string
enum: [pending, confirmed, shipped, delivered, cancelled]
total:
type: number
format: double
minimum: 0
items:
type: array
items:
$ref: '#/components/schemas/OrderItem'
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
OrderItem:
type: object
required: [productId, quantity, price]
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
price:
type: number
format: double
minimum: 0
CreateOrderRequest:
type: object
required: [items]
properties:
items:
type: array
minItems: 1
items:
type: object
required: [productId, quantity]
properties:
productId:
type: string
quantity:
type: integer
minimum: 1
CreatePaymentIntentRequest:
type: object
required: [amount, currency]
properties:
amount:
type: integer
description: Amount in cents
minimum: 1
currency:
type: string
pattern: '^[A-Z]{3}$'
description:
type: string
PaymentIntent:
type: object
required: [id, clientSecret, status, amount, currency]
properties:
id:
type: string
clientSecret:
type: string
status:
type: string
enum: [requires_payment_method, processing, succeeded, failed]
amount:
type: integer
currency:
type: string
OrderListResponse:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
pagination:
type: object
required: [hasMore, cursor]
properties:
hasMore:
type: boolean
cursor:
type: string
nullable: true
responses:
ValidationError:
description: Validation failed
content:
application/json:
schema:
type: object
required: [code, message]
properties:
code:
type: string
enum: [VALIDATION_ERROR]
message:
type: string
UnauthorizedError:
description: Missing or invalid authentication
content:
application/json:
schema:
type: object
required: [code, message]
properties:
code:
type: string
enum: [UNAUTHORIZED]
message:
type: string
NotFoundError:
description: Resource not found
content:
application/json:
schema:
type: object
required: [code, message]
properties:
code:
type: string
enum: [NOT_FOUND]
message:
type: string
InternalServerError:
description: Server error
content:
application/json:
schema:
type: object
required: [code, message]
properties:
code:
type: string
enum: [INTERNAL_ERROR]
message:
type: string
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWTDesigning Before Coding
Write your spec first, get stakeholder approval, then implement. This prevents "we should have done it this way" conversations after the code is written.
// Start with spec review checklist:
// 1. Every endpoint has clear purpose
// 2. Request/response shapes are minimal and correct
// 3. All error cases are documented
// 4. Pagination/filtering handled consistently
// 5. Authentication/authorization clear on each endpoint
// 6. No fields that aren't actually needed
// Example: Design iteration
// First draft: response includes everything
// Order:
// - id, userId, status, total, items
// - customerEmail, customerPhone, customerAddress
// - internalNotes, fulfillmentWarehouse, trackingNumber
// - metrics: conversionRate, customerLifetimeValue
// Feedback: Clients don't need internal fields, separate checkout flow
// Second draft: Minimal public response
// Order:
// - id, userId, status, total, items
// - createdAt, updatedAt
// Separate endpoints for different concerns:
// GET /orders/{id} - customer view
// GET /orders/{id}/tracking - fulfillment view
// GET /internal/orders/{id} - admin viewopenapi-typescript: Auto-Generate Types
openapi-typescript reads your OpenAPI spec and generates TypeScript types. No manual type definitions.
// Generate types from spec
// Command: npx openapi-typescript openapi.yaml -o src/api.types.ts
// src/api.types.ts - GENERATED, don't edit manually
export interface Order {
id: string
userId: string
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
total: number
items: OrderItem[]
createdAt: string
updatedAt: string
}
export interface OrderItem {
productId: string
quantity: number
price: number
}
export interface CreateOrderRequest {
items: Array<{
productId: string
quantity: number
}>
}
export interface PaymentIntent {
id: string
clientSecret: string
status: 'requires_payment_method' | 'processing' | 'succeeded' | 'failed'
amount: number
currency: string
}
// Now use generated types in your implementation
import type { Order, CreateOrderRequest } from './api.types'
export class OrderService {
async createOrder(request: CreateOrderRequest): Promise<Order> {
// Request is type-checked against spec
// Return type must match spec exactly
return {
id: crypto.randomUUID(),
userId: 'user123',
status: 'pending',
total: 99.99,
items: request.items.map(item => ({
...item,
price: item.price || 0
})),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
}
}Request/Response Validation Middleware From Spec
Auto-generate validation middleware from your OpenAPI spec using openapi-backend:
// src/middleware/openapi-validator.ts
import OpenAPIBackend from 'openapi-backend'
import type { Request, Response, NextFunction } from 'express'
const api = new OpenAPIBackend({ definition: './openapi.yaml' })
api.init()
export function validateRequest(req: Request, res: Response, next: NextFunction) {
try {
// Validate incoming request matches spec
const validationResult = api.validateRequest(req)
if (validationResult.errors) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'Request does not match API specification',
errors: validationResult.errors
})
}
next()
} catch (error) {
next(error)
}
}
export function validateResponse(req: Request, res: Response, next: NextFunction) {
const originalJson = res.json.bind(res)
res.json = function (body: any) {
try {
const validationResult = api.validateResponse(req, body)
if (validationResult.errors) {
console.error('Response validation failed:', validationResult.errors)
// Log but don't fail - we have a contract breach in our code
}
return originalJson(body)
} catch (error) {
console.error('Response validation error:', error)
return originalJson(body)
}
}
next()
}
// Use in Express app
app.use(validateRequest)
app.use(validateResponse)
app.post('/orders', async (req: Request, res: Response) => {
// Request guaranteed to match CreateOrderRequest schema
// Response must match Order schema or validation middleware logs error
const order = await orderService.createOrder(req.body)
res.status(201).json(order)
})Mock Server From Spec (Prism)
Need to test client code before API is ready? Generate a mock server from your spec:
# Install Prism
npm install -g @stoplight/prism-cli
# Start mock server from spec
prism mock openapi.yaml --host 0.0.0.0 --port 4010
# Now clients can test against http://localhost:4010
curl http://localhost:4010/orders
# Returns valid mock data matching spec// Client can develop against mock server
// src/api-client/orders-client.test.ts
const API_URL = process.env.API_URL || 'http://localhost:4010'
describe('Orders Client', () => {
it('should fetch orders from mock server', async () => {
const client = new OrdersAPIClient(API_URL)
const orders = await client.listOrders()
// Mock server returns valid Order[] even though implementation doesn't exist yet
expect(Array.isArray(orders.data)).toBe(true)
expect(orders.data[0]).toHaveProperty('id')
expect(orders.data[0]).toHaveProperty('status')
})
})Documentation Generation
Generate beautiful, interactive documentation automatically:
# Generate ReDoc documentation
docker run --rm -v $(pwd):/spec \
redocly/redoc:next \
build-docs openapi.yaml -o /spec/docs/index.html
# Or use Swagger UI
docker run -p 8080:8080 \
-e SPEC_URL=file:///spec/openapi.yaml \
-v $(pwd):/spec \
swaggerapi/swagger-ui
# Now visit http://localhost:8080 for interactive API docsBreaking Change Detection (oasdiff in CI)
Prevent accidental breaking changes by running oasdiff in CI:
# .github/workflows/openapi-changes.yml
name: OpenAPI Breaking Changes
on:
pull_request:
paths:
- openapi.yaml
jobs:
check-breaking-changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check for breaking changes
run: |
# Install oasdiff
go install github.com/Tufin/oasdiff@latest
# Get base spec from main branch
git show origin/main:openapi.yaml > openapi.base.yaml
# Compare specs
oasdiff breaking \
openapi.base.yaml \
openapi.yaml \
--fail-on-diff
- name: Comment on PR
if: failure()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'OpenAPI breaking changes detected! Run `oasdiff breaking openapi.base.yaml openapi.yaml` locally to review.'
})// Example breaking changes that oasdiff detects:
// 1. Removed endpoint
// 2. Removed required field from response
// 3. Changed field type
// 4. Made optional field required
// 5. Removed value from enum
// 6. Changed security requirements
// oasdiff marks as safe:
// - Adding new endpoint
// - Adding optional field to response
// - Adding value to enum
// - Making required field optional (backwards compatible)Versioning in Spec vs URL
Should you version in the URL (/v1/orders) or in the spec? Generally prefer the spec:
# Spec versioning: Keep one URL for all versions
paths:
/orders:
get:
x-api-version: '1.0' # Track version in spec
# Implementation handles all versions
# URL versioning: Separate endpoints
paths:
/v1/orders:
get:
# Old implementation
/v2/orders:
get:
# New implementation// When to use URL versioning:
// - Very different schemas between versions
// - Long-term support needed (v1 stays running for years)
// - Different teams maintaining versions
// When to use spec versioning:
// - Small, iterative changes
// - Spec version in info section
// - Single codebase handles all versions
// Hybrid approach:
info:
version: 2.1.0 // Semantic versioning for spec
paths:
/orders:
post:
x-breaking-since: 2.0.0 // Document when breaking changes happened
x-deprecated-since: 1.5.0Spec-First vs Code-First Decision
| Aspect | Spec-First | Code-First |
|---|---|---|
| Design approval | Before coding | After implementation |
| Documentation | Auto-generated from spec | Manual/lagging |
| Breaking changes | Caught in design review | Caught in testing |
| Type safety | Generated from spec | Manual definitions |
| Client readiness | Clients can start with mock | Clients wait for implementation |
| Documentation accuracy | Always in sync | Often stale |
| Refactoring | Update spec, regenerate code | Update code, update docs manually |
Choose spec-first for: public APIs, teams that aren't co-located, systems with many clients Choose code-first for: internal APIs, small teams, rapid prototypes
Checklist
- OpenAPI 3.1 spec covers all endpoints
- All request/response shapes documented with examples
- Every endpoint has proper security annotations
- Error responses have consistent structure
- Types generated from spec with openapi-typescript
- Validation middleware auto-generated from spec
- Mock server running for parallel client development
- Breaking change detection in CI (oasdiff)
- Documentation auto-generated and published
- Spec versioned in git, changes reviewed in PR
Conclusion
When your API contract is a first-class artifact (the spec), everything else flows from it: types, validation, documentation, mocks, breaking change detection. You're not documenting code that might not match docs. You're implementing a contract you've already agreed on. This shifts bugs from production to design review, where they cost nothing to fix.
Advertisement