All Posts
API DesignRESTFlaskBackendMicroservices

Designing REST APIs That Scale: Principles from 20+ Production Services

15 August 2025·9 min read·Harshit Gupta
TL;DR

Good REST API design comes down to six principles: resource-oriented URLs (nouns, not verbs), consistent error responses (machine-readable error codes, not just HTTP status), idempotent writes (PUT/PATCH with idempotency keys for POST), pagination from day one, versioning from day one, and contract testing between services. Skip any one of these and you'll pay for it later.

The API Is the Product

Internal microservice APIs are products. Their consumers are other developers — on your team or in partner integrations. A poorly designed API creates bugs in the consumers, increases support burden, and makes refactoring expensive because every change breaks something downstream.

The principles below came from designing APIs that power integrations with 100+ enterprise LMS and HR systems. When you have that many consumers, inconsistency is a support ticket waiting to happen. Consistency is the highest form of API quality.

Principle 1: Resources and URLs

URLs should identify resources (nouns), not actions (verbs). HTTP methods provide the verb. This is REST's core insight and still the most frequently violated:

# Bad — action-based URLs
POST /issueCredential
POST /revokeCredential
GET /getCredentialById?id=123
POST /searchCredentials

# Good — resource-based URLs
POST /credentials                    # create
GET  /credentials/{id}               # read
PUT  /credentials/{id}               # full update
PATCH /credentials/{id}              # partial update
DELETE /credentials/{id}             # delete
POST /credentials/{id}/revocations   # action as sub-resource

The sub-resource pattern (/credentials/{id}/revocations) elegantly handles actions that don't map cleanly to CRUD. Revocation isn't a state change on the credential — it creates a new revocation record. Modelling it as a sub-resource POST keeps the URL structure consistent.

Principle 2: Consistent Error Responses

HTTP status codes are not enough. A 400 Bad Request could mean 20 different things. Consumers need machine-readable error codes to handle different error conditions programmatically:

from flask import jsonify

# Standard error response shape — every API should have one
def error_response(status_code: int, error_code: str, message: str, details: dict = None):
    body = {
        "error": {
            "code": error_code,           # machine-readable, stable identifier
            "message": message,           # human-readable for developers
            "details": details or {},     # structured additional context
            "request_id": g.request_id    # for log correlation
        }
    }
    return jsonify(body), status_code

# Examples of consistent error codes
# 400: VALIDATION_ERROR, MISSING_REQUIRED_FIELD, INVALID_FORMAT
# 401: AUTHENTICATION_REQUIRED, TOKEN_EXPIRED, INVALID_TOKEN
# 403: PERMISSION_DENIED, SUBSCRIPTION_LIMIT_EXCEEDED
# 404: CREDENTIAL_NOT_FOUND, USER_NOT_FOUND
# 409: CREDENTIAL_ALREADY_ISSUED, EMAIL_ALREADY_EXISTS
# 429: RATE_LIMIT_EXCEEDED
# 500: INTERNAL_ERROR (never expose internals)
Include a request_id in every response

Generate a UUID at the start of every request, attach it to all log lines, and return it in every response (including errors). When a consumer reports an error, you can instantly pull every log line for that specific request. This is the single most valuable debugging feature you can add to an API.

Principle 3: Idempotency Keys for POST

GET, PUT, and DELETE are naturally idempotent — calling them twice produces the same result as calling once. POST is not. Network failures cause clients to retry POSTs, creating duplicates. Idempotency keys make POST safe to retry:

from flask import request, g
import hashlib

@app.route('/credentials', methods=['POST'])
def create_credential():
    idempotency_key = request.headers.get('Idempotency-Key')
    if not idempotency_key:
        return error_response(400, 'MISSING_IDEMPOTENCY_KEY',
            'POST requests require an Idempotency-Key header')

    # Check if we've already processed this key
    cache_key = f"idempotency:{idempotency_key}"
    cached_response = redis_client.get(cache_key)
    if cached_response:
        return jsonify(json.loads(cached_response)), 200  # replay stored response

    # Process the request
    result = credential_service.create(request.json)

    # Cache the response for 24 hours
    redis_client.setex(cache_key, 86400, json.dumps(result))
    return jsonify(result), 201

Principle 4: Pagination from Day One

Never return an unbounded list. An endpoint that returns all credentials for an organization works fine at 100 records. At 50,000 records, it crashes the client, timeouts the server, and the fix is a breaking API change. Build pagination into every list endpoint from the start:

@app.route('/credentials')
def list_credentials():
    # Cursor-based pagination
    cursor = request.args.get('cursor')  # encoded last-seen ID
    limit = min(int(request.args.get('limit', 20)), 100)  # max 100 per page
    org_id = g.user['org_id']

    query = db.session.query(Credential).filter_by(org_id=org_id)
    if cursor:
        last_id = decode_cursor(cursor)
        query = query.filter(Credential.id > last_id)

    credentials = query.order_by(Credential.id).limit(limit + 1).all()

    has_next = len(credentials) > limit
    items = credentials[:limit]

    return jsonify({
        "data": [c.to_dict() for c in items],
        "pagination": {
            "has_next": has_next,
            "next_cursor": encode_cursor(items[-1].id) if has_next else None,
            "limit": limit
        }
    })

Principle 5: Versioning from Day One

API versioning is always easier to add early than to retrofit after breaking consumers. We use URL versioning (/v1/credentials) because it is explicit and cache-friendly:

from flask import Blueprint

v1_bp = Blueprint('v1', __name__, url_prefix='/v1')
v2_bp = Blueprint('v2', __name__, url_prefix='/v2')

# When breaking changes are needed, add a v2 endpoint
# Keep v1 alive for a deprecation period (6+ months for enterprise clients)
# Return Deprecation headers on v1 to signal to consumers
Deprecation headers are professional courtesy

When an API version is deprecated, return Deprecation: true and Sunset: <date> headers on every response. Good API clients log these warnings. This gives consumers time to migrate without being blindsided by a breaking change.

Key Takeaways

  • URLs are nouns (resources), HTTP methods are verbs — actions become sub-resource POSTs
  • Machine-readable error codes in a consistent envelope — HTTP status alone is insufficient
  • Idempotency keys on POST make retries safe and eliminate duplicate creation bugs
  • Cursor-based pagination on every list endpoint — no unbounded responses, ever
  • URL versioning from day one — retrofitting versioning onto a live API is painful
  • Request IDs in every response are the single highest-leverage debugging investment
Back to All Posts

Written by Harshit Gupta

© 2026 Harshit Gupta · New Delhi, India