How to Implement API-First Architecture: Design, Versioning, and Testing
Build APIs that last. Covers OpenAPI spec design, versioning strategies, authentication patterns, rate limiting, and contract testing for enterprise APIs.
API-first means designing your API contract before writing any implementation code. This approach eliminates the “build it and see” cycle that creates breaking changes, inconsistent naming, and developer frustration.
Step 1: Design the Contract First
1.1 OpenAPI Specification
# openapi.yaml
openapi: 3.1.0
info:
title: Customer Management API
version: 2.0.0
description: Manage customer records and their associated orders.
servers:
- url: https://api.company.com/v2
description: Production
- url: https://api-staging.company.com/v2
description: Staging
paths:
/customers:
get:
summary: List customers
operationId: listCustomers
parameters:
- name: page
in: query
schema: { type: integer, default: 1, minimum: 1 }
- name: per_page
in: query
schema: { type: integer, default: 20, maximum: 100 }
- name: sort
in: query
schema: { type: string, enum: [created_at, name, revenue] }
responses:
'200':
description: Customer list
content:
application/json:
schema:
$ref: '#/components/schemas/CustomerList'
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: Create a customer
operationId: createCustomer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateCustomer'
responses:
'201':
description: Customer created
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
components:
schemas:
Customer:
type: object
required: [id, name, email, created_at]
properties:
id:
type: string
format: uuid
name:
type: string
example: "Acme Corp"
email:
type: string
format: email
revenue:
type: number
format: double
created_at:
type: string
format: date-time
CreateCustomer:
type: object
required: [name, email]
properties:
name:
type: string
minLength: 1
maxLength: 200
email:
type: string
format: email
CustomerList:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Customer'
meta:
$ref: '#/components/schemas/Pagination'
Pagination:
type: object
properties:
page: { type: integer }
per_page: { type: integer }
total: { type: integer }
total_pages: { type: integer }
1.2 Validate Before Building
# Validate your OpenAPI spec
npx @redocly/cli lint openapi.yaml
# Generate API docs
npx @redocly/cli build-docs openapi.yaml -o docs/index.html
# Generate client SDK
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./sdk
Step 2: Choose Your Versioning Strategy
| Strategy | URL Path | Header | Query Param |
|---|---|---|---|
| Example | /v2/customers | API-Version: 2 | ?version=2 |
| Discoverability | ✅ Obvious | ❌ Hidden | ⚠️ Easy to forget |
| Caching | ✅ Cache-friendly | ❌ Breaks caches | ✅ Cache-friendly |
| Implementation | Simple routing | Middleware | Middleware |
| Recommendation | ✅ Best for REST | Good for internal | Avoid |
Breaking vs Non-Breaking Changes
Non-Breaking (no version bump):
✅ Adding a new optional field to a response
✅ Adding a new endpoint
✅ Adding a new optional query parameter
✅ Deprecating a field (but keeping it)
Breaking (requires version bump):
❌ Removing a field from a response
❌ Renaming a field
❌ Changing a field's type
❌ Making an optional field required
❌ Changing error response format
Step 3: Implement Authentication
JWT + API Key Pattern
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import HTTPBearer, APIKeyHeader
import jwt
app = FastAPI()
bearer = HTTPBearer()
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def authenticate(
token: str = Depends(bearer),
api_key: str = Security(api_key_header)
):
"""Support both JWT and API Key authentication"""
if api_key:
# Validate API key against database
key_record = await db.api_keys.find_one({"key": api_key, "active": True})
if not key_record:
raise HTTPException(401, "Invalid API key")
return {"type": "api_key", "client": key_record["client_id"]}
if token:
try:
payload = jwt.decode(token.credentials, SECRET, algorithms=["HS256"])
return {"type": "jwt", "user_id": payload["sub"]}
except jwt.InvalidTokenError:
raise HTTPException(401, "Invalid token")
raise HTTPException(401, "Authentication required")
@app.get("/v2/customers")
async def list_customers(auth=Depends(authenticate)):
# auth contains the authenticated identity
...
Step 4: Implement Rate Limiting
from fastapi import Request
from collections import defaultdict
import time
# Simple in-memory rate limiter
class RateLimiter:
def __init__(self, max_requests: int = 100, window_seconds: int = 60):
self.max_requests = max_requests
self.window = window_seconds
self.requests = defaultdict(list)
def is_allowed(self, client_id: str) -> tuple[bool, dict]:
now = time.time()
# Clean old entries
self.requests[client_id] = [
t for t in self.requests[client_id]
if now - t < self.window
]
remaining = self.max_requests - len(self.requests[client_id])
if remaining <= 0:
return False, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(int(self.requests[client_id][0] + self.window))
}
self.requests[client_id].append(now)
return True, {
"X-RateLimit-Limit": str(self.max_requests),
"X-RateLimit-Remaining": str(remaining - 1),
}
rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
Step 5: Contract Testing
# Schemathesis — automated API contract testing
# Install: pip install schemathesis
import schemathesis
schema = schemathesis.from_url(
"https://api-staging.company.com/openapi.yaml"
)
@schema.parametrize()
def test_api_contract(case):
"""
Automatically generates test cases from your OpenAPI spec.
Tests: valid inputs, edge cases, invalid inputs, auth requirements.
"""
response = case.call()
case.validate_response(response)
# CLI alternative
schemathesis run https://api-staging.company.com/openapi.yaml \
--checks all \
--base-url https://api-staging.company.com \
--header "Authorization: Bearer $TOKEN"
API Design Checklist
- OpenAPI spec written and validated before coding
- Consistent naming (camelCase or snake_case — pick one)
- Pagination on all list endpoints
- Versioning strategy chosen (URL path recommended)
- Authentication (JWT + API Key support)
- Rate limiting with standard headers
- Error responses follow RFC 7807 (Problem Details)
- Contract tests running in CI
- API documentation auto-generated from spec
- Breaking change policy documented
:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For API architecture reviews, visit garnetgrid.com. :::