API Design Best Practices: Building RESTful and GraphQL Services

Team 4 min read

#api

#rest

#graphql

#backend

#webdev

Great APIs are invisible. They work intuitively, handle errors gracefully, and evolve without breaking existing integrations. Whether you’re building REST or GraphQL, certain principles remain universal. This expanded guide summarizes practical rules, anti-patterns, examples, and a checklist to help you design APIs that are reliable, discoverable, and pleasant to use.

Core principles

  • Consistency: Use consistent naming, error formats, pagination, and authentication flows across your API surface. Predictability reduces cognitive load for integrators.
  • Simplicity: Favor simple, well-documented endpoints and schemas. Avoid exposing internal implementation details.
  • Backwards compatibility: Evolve APIs in ways that preserve existing clients (additive changes, opt-in features, versioning when necessary).
  • Observability: Emit structured logs, metrics, and traces. Good telemetry accelerates debugging and maintains trust.

Uniform resource design (REST)

  • Resource nouns, not verbs: Use /orders and /orders/{id} rather than /getOrders or /createOrder.
  • HTTP verbs: Use GET for safe operations, POST for creation, PUT for full replace, PATCH for partial updates, DELETE for removal.
  • Status codes:
    • 200 OK for successful reads/updates (when returning a body).
    • 201 Created for successful resource creation (include Location header).
    • 204 No Content for successful operations with no body.
    • 400 Bad Request for validation or malformed requests.
    • 401 Unauthorized for missing/invalid credentials.
    • 403 Forbidden for authenticated but unauthorized actions.
    • 404 Not Found for missing resources.
    • 409 Conflict for version/unique constraint conflicts.
    • 422 Unprocessable Entity for semantic validation errors.
  • Pagination: Prefer cursor-based pagination for large/ordered datasets; support limit/after or page/size when simpler.
  • Filtering & sorting: Use query parameters (e.g., ?status=active&sort=-createdAt). Keep param naming consistent across endpoints.
  • Content negotiation & media types: Use Accept and Content-Type headers for different representations (JSON, XML) only when needed.
  • HATEOAS (optional): Include links when hypermedia helps clients discover flows (use sparingly — many teams prefer explicit docs and SDKs).

GraphQL considerations

  • Strong typing: Use a clear schema with descriptions, nullable vs non-nullable fields, and input types.
  • Avoid overfetching/underfetching: GraphQL solves this but watch out for unnecessarily complex schemas that force clients into heavy queries.
  • Batching & dataloaders: Prevent N+1 query problems using batching on resolvers (e.g., dataloader pattern).
  • Mutations design: Model logically grouped side-effect operations as separate mutations. Return the affected objects plus errors in a predictable shape.
  • Error handling: Return structured errors following GraphQL conventions and consider an extensions payload for machine-readable codes.
  • Versioning: Prefer schema evolution (additive fields). When breaking changes are unavoidable, consider a new root field or a new endpoint.

Error formats and validation

  • Use a consistent JSON error envelope:
    • Example:
      {
        "error": {
          "code": "INVALID_INPUT",
          "message": "The field 'email' must be a valid email address.",
          "details": { "field": "email" }
        }
      }
  • Include machine-friendly codes and human-friendly messages.
  • Validate early and return comprehensive validation results rather than failing on the first error.

Security and rate limits

  • Authentication: Use industry-standard schemes (OAuth2, JWT with proper rotation, API keys with scopes).
  • Authorization: Enforce least privilege; implement role- or permission-based checks server-side.
  • Input sanitization: Never trust client input — escape/validate before use.
  • Rate limiting & throttling: Apply per-client quotas and graceful 429 responses with Retry-After header.
  • Transport security: Enforce TLS, strong ciphers, and HSTS.

Caching and performance

  • Idempotency & caching headers: Use Cache-Control, ETag, and Last-Modified where appropriate.
  • Use server-side caching for expensive computations and CDN for static assets.
  • Instrument performance: Track latency percentiles, error rates, and throughput. Optimize the common path first.

Documentation & discoverability

  • Source-of-truth docs: Maintain OpenAPI specs for REST and introspection + schema docs for GraphQL.
  • Provide examples: Show sample requests/responses and edge-case examples.
  • SDKs & codegen: Generate client SDKs where it increases adoption and reduces integration errors.
  • Interactive tools: Offer Swagger UI, Postman collections, or GraphiQL to try APIs quickly.

Testing strategy

  • Contract tests: Validate API contracts against generated clients or schema-driven test suites.
  • Integration tests: Exercise real endpoints with a test database or sandbox.
  • End-to-end scenarios: Validate full workflows that clients rely on.
  • Mocking for clients: Provide mock servers or stubs for offline development.

Versioning strategies

  • URL versioning (e.g., /v1/resource) — explicit but can proliferate.
  • Header-based versioning — less visible, but cleaner URIs.
  • Schema evolution — for GraphQL prefer additive changes and deprecation directives.
  • Deprecation policy: Announce deprecations, provide migration guides, and keep old versions available for a defined timeframe.

Example: REST error response (normalized)

{
  "status": 422,
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Validation failed for the request.",
    "fields": [
      { "name": "email", "message": "Invalid email format." },
      { "name": "password", "message": "Password is too weak." }
    ]
  }
}