DEV Community

Sir Max
Sir Max

Posted on

4 API Design Rules I Learned the Hard Way (With Real Code)

4 API Design Rules I Learned the Hard Way (With Real Code)

I've spent the last few years building and maintaining APIs — both internal microservices and public-facing REST endpoints. Along the way I broke every "best practice" in the book. Each time it cost me — late-night pages, angry users, or embarrassing rollbacks.

Here are the four rules I now enforce on every project, with the real code that could have prevented each disaster.


1. Never Ship Without Versioning — Even Internally

I used to think versioning was for "public APIs" only. Internal services? "We'll just coordinate deployments." This worked for about three months. Then two teams deployed on the same day and suddenly 40% of requests to our billing service were returning 500s.

The mistake: I changed a field from amount (integer, cents) to amount (float, dollars) because "it was cleaner." The consuming team had no warning.

The fix — URL-based versioning from day one:

# fastapi example
from fastapi import FastAPI, APIRouter

app = FastAPI()
v1 = APIRouter(prefix="/api/v1")
v2 = APIRouter(prefix="/api/v2")

@v1.get("/invoices/{id}")
async def get_invoice_v1(id: str):
    return {"amount": 1500}  # cents

@v2.get("/invoices/{id}")
async def get_invoice_v2(id: str):
    return {"amount": 15.00}  # dollars, with deprecation header

app.include_router(v1)
app.include_router(v2)
Enter fullscreen mode Exit fullscreen mode

The /api/v1 endpoint kept running for six months with a Sunset header. Zero breaking changes, zero late-night pages. The cost? One extra directory in your project structure. That's it.

Rule: If another team (or future you) depends on your endpoint, version it. Even if it's "just internal."


2. Don't Over-Engineer Error Responses

Early in my career, I built what I thought was a "comprehensive" error format:

{
  "error": {
    "code": "VALIDATION_ERR_0023",
    "message": "Field 'email' failed regex validation",
    "details": {
      "field": "email",
      "value": "not-an-email",
      "rule": "RFC5322",
      "rule_version": "3.4",
      "suggestion": "Provide a valid email address"
    },
    "request_id": "req_8a7f3c",
    "timestamp": "2024-01-15T08:23:41Z",
    "documentation_url": "/docs/errors#VALIDATION_ERR_0023"
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at that. Eight fields. A documentation_url that went to a 404 page. A rule_version nobody ever used. A suggestion that was literally just the error message rephrased.

The client developers hated it. They just needed the HTTP status code and a human-readable message. Everything else was noise that made their error-handling code longer and harder to debug.

What actually works:

from fastapi import HTTPException

@app.get("/users/{id}")
async def get_user(id: int):
    user = await db.fetch_user(id)
    if not user:
        raise HTTPException(
            status_code=404,
            detail=f"User {id} not found"
        )
    return user
Enter fullscreen mode Exit fullscreen mode

That's it. HTTP status code carries the semantics. detail carries the message. If the client needs structured error codes, add ONE optional field:

{ "detail": "User 42 not found", "code": "not_found" }
Enter fullscreen mode Exit fullscreen mode

Two fields. Maximum. Your client developers will thank you.

Rule: Start with { status_code, detail }. Add fields only when a specific client asks for them — and then add exactly one at a time.


3. Rate Limiting Isn't Optional — It's a Feature

I launched a free tier of an API without rate limiting because "we're small, nobody will abuse it." Within a week, someone's misconfigured cron job was hammering our /search endpoint at 300 requests per second — from a single IP. Our $40/month VPS fell over, and paying customers couldn't access the service.

The fix took 15 minutes with an off-the-shelf library:

import time
from collections import defaultdict

class SimpleRateLimiter:
    def __init__(self, max_requests: int, window_seconds: int = 60):
        self.max_requests = max_requests
        self.window = window_seconds
        self.requests: dict[str, list[float]] = defaultdict(list)

    def is_allowed(self, key: str) -> bool:
        now = time.time()
        cutoff = now - self.window
        # Clean old entries
        self.requests[key] = [t for t in self.requests[key] if t > cutoff]
        if len(self.requests[key]) >= self.max_requests:
            return False
        self.requests[key].append(now)
        return True

# Usage
limiter = SimpleRateLimiter(max_requests=100)

@app.get("/search")
async def search(q: str, request: Request):
    client_ip = request.client.host
    if not limiter.is_allowed(client_ip):
        raise HTTPException(status_code=429, detail="Too many requests")
    return await do_search(q)
Enter fullscreen mode Exit fullscreen mode

For production, use something like Redis + a token bucket. But even this 20-line solution would have saved me from that outage. The key insight: rate limiting protects your paying users from free-tier abuse.

Rule: Every endpoint gets a rate limit. Start conservative (100 req/min for free tier), tighten based on real traffic.


4. Deprecation Is a Process, Not an Announcement

The worst API mistake I ever made: I renamed a field from user_name to username, deployed it on a Friday afternoon, and went home. Monday morning, 17 integration tests in a downstream service were failing. The team lead was not happy.

The fix isn't complicated — it just requires patience:

from datetime import datetime, timedelta

@app.get("/api/v2/profile")
async def get_profile():
    profile = {
        "username": "sirmax",
        # Deprecated — remove after 2025-03-01
        "user_name": "sirmax"
    }
    return profile

@app.middleware("http")
async def add_deprecation_header(request: Request, call_next):
    response = await call_next(request)
    if request.url.path.startswith("/api/v1"):
        sunset_date = (datetime.now() + timedelta(days=90)).strftime("%Y-%m-%d")
        response.headers["Sunset"] = sunset_date
        response.headers["Deprecation"] = "true"
    return response
Enter fullscreen mode Exit fullscreen mode

Three steps, in order:

  1. Add the new field, keep the old one. Let clients migrate on their schedule.
  2. Add deprecation headers. Sunset tells them when it's going away. Deprecation: true flags it in logs.
  3. Wait. 90 days minimum. Email your API consumers. Check your logs to see who's still hitting the old endpoints. Then — and only then — remove the old field.

Rule: Never remove a field, endpoint, or behavior without at least one deprecation window. Your "quick rename" is someone else's production incident.


What Stuck With Me

Looking back, every one of these rules boils down to the same thing: assume your API has users you don't know about. The internal tool you built for one team? Another team found it and depends on it. The field you named hastily? Someone's dashboard relies on that exact key.

A good API isn't the one with the cleverest design. It's the one that doesn't break unexpectedly at 3 AM.


What API design rules have you learned the hard way? I'd love to hear your stories in the comments.

Top comments (0)