Published on

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

Authors

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: 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

AspectSpec-FirstCode-First
Design approvalBefore codingAfter implementation
DocumentationAuto-generated from specManual/lagging
Breaking changesCaught in design reviewCaught in testing
Type safetyGenerated from specManual definitions
Client readinessClients can start with mockClients wait for implementation
Documentation accuracyAlways in syncOften stale
RefactoringUpdate spec, regenerate codeUpdate 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.