- Published on
Design-First APIs With OpenAPI — Schema as Single Source of Truth
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Designing Before Coding
- openapi-typescript: Auto-Generate Types
- Request/Response Validation Middleware From Spec
- Mock Server From Spec (Prism)
- Documentation Generation
- Breaking Change Detection (oasdiff in CI)
- Versioning in Spec vs URL
- Spec-First vs Code-First Decision
- Checklist
- Conclusion
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: JWT
Designing 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 view
openapi-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 docs
Breaking 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.0
Spec-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.