Verified by Garnet Grid

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. The alternative — code-first APIs — leads to contracts that leak implementation details, naming that changes with refactors, and consumers that break whenever you ship.

Companies with API-first practices ship 40% fewer breaking changes and onboard new API consumers 3x faster. The investment is a spec file and a review process. The return is an API that lasts years instead of months.


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

1.3 API Design Conventions

ConventionGoodBadWhy
Resource naming/customers/{id}/orders/getCustomerOrdersREST uses nouns, not verbs
Plural resources/customers/customerConsistent collection naming
Nested resources max 2 levels/customers/{id}/orders/customers/{id}/orders/{id}/itemsDeep nesting is hard to navigate
Consistent casingsnake_case or camelCase (pick one)Mix of bothReduces consumer confusion
ISO 8601 dates2026-03-02T14:30:00Z03/02/2026 or Unix timestampUniversal, timezone-aware
Envelope responses{ "data": [...], "meta": {} }Raw array [...]Room for pagination, metadata

Step 2: Choose Your Versioning Strategy

StrategyURL PathHeaderQuery Param
Example/v2/customersAPI-Version: 2?version=2
Discoverability✅ Obvious❌ Hidden⚠️ Easy to forget
Caching✅ Cache-friendly❌ Breaks caches✅ Cache-friendly
ImplementationSimple routingMiddlewareMiddleware
Recommendation✅ Best for RESTGood for internalAvoid

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

Deprecation Policy

PhaseActionDuration
AnnounceAdd Deprecation header + changelog entryDay 0
WarningAPI returns Sunset header with date30 days
SunsetOld version returns 410 Gone90 days after announcement
RemoveClean up old codeAfter sunset
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 01 Jun 2026 00:00:00 GMT
Link: <https://api.company.com/v3/customers>; rel="successor-version"

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

Authentication Method Selection

MethodBest ForSecurityComplexity
API KeyServer-to-server, simple integrationsMedium (rotate regularly)Low
JWTUser-facing apps, fine-grained permissionsHigh (short-lived, signed)Medium
OAuth 2.0Third-party integrations, delegated accessHighest (scoped, revocable)High
mTLSInfrastructure, zero-trust internal APIsHighest (cert-based)High

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)

Rate Limit Tiers

TierLimitWindowUse Case
Free100 requestsPer minutePublic API, trial users
Pro1,000 requestsPer minutePaid customers
Enterprise10,000 requestsPer minuteDedicated customers
InternalNo limitInternal services (but monitor)

Step 5: Error Response Standards (RFC 7807)

{
  "type": "https://api.company.com/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "The 'email' field must be a valid email address.",
  "instance": "/v2/customers",
  "errors": [
    {
      "field": "email",
      "message": "Must be a valid email address",
      "value": "not-an-email"
    }
  ]
}

Standard HTTP Status Codes

CodeMeaningWhen to Use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST (resource created)
204No ContentSuccessful DELETE
400Bad RequestInvalid JSON, malformed request
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn’t exist
409ConflictDuplicate resource, version conflict
422Unprocessable EntityValidation errors
429Too Many RequestsRate limited
500Internal Server ErrorUnexpected server failure

Step 6: 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 any coding begins
  • API design conventions documented (naming, casing, date format)
  • Consistent naming (camelCase or snake_case — pick one, never mix)
  • Pagination on all list endpoints (page + per_page + total)
  • Versioning strategy chosen (URL path recommended for REST)
  • Deprecation policy documented with sunset timeline
  • Authentication supports both JWT and API Key
  • Rate limiting with standard headers (X-RateLimit-*)
  • Error responses follow RFC 7807 (Problem Details)
  • Contract tests running in CI on every PR
  • API documentation auto-generated from OpenAPI spec
  • Breaking change policy documented and enforced via PR review
  • Client SDK auto-generated from spec for major languages

:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For API architecture reviews, visit garnetgrid.com. :::

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →