TheThunderclap
System Design Backend GraphQL REST

API Design Patterns That Scale

Practical API contracts, versioning strategies, and GraphQL vs REST trade-offs for modern engineering teams.

P

Priya Sharma

API Platform Lead

📅 10 February 2025
⏱ 9 min read

Introduction

A well-designed API is a promise to your consumers. Change it carelessly and you break clients in production. This guide covers the design decisions that separate an API that scales gracefully from one that becomes a maintenance nightmare.

RESTful Resource Modelling

REST is not just "use HTTP verbs". It's about modelling your domain as resources and exposing them predictably.

Golden rules: Use nouns (not verbs) for endpoints Nest resources to show relationships — but never more than 2 levels deep Always return consistent response envelopes

routes.ts
typescript
                                            "hl-keyword">import { Router } "hl-keyword">from 'express';

"hl-keyword">const router = Router();

"hl-keyword">class="hl-comment">// ✅ Good: noun-based, consistent nesting
router.get('/users',                    listUsers);
router.get('/users/:id',                getUser);
router.post('/users',                   createUser);
router.patch('/users/:id',              updateUser);
router.delete('/users/:id',             deleteUser);

"hl-keyword">class="hl-comment">// Nested: posts belonging to a user
router.get('/users/:id/posts',          listUserPosts);
router.post('/users/:id/posts',         createUserPost);

"hl-keyword">class="hl-comment">// ❌ Bad: verb "hl-keyword">in URL, inconsistent nesting
"hl-keyword">class="hl-comment">// router.post('/getUser',              getUser);
"hl-keyword">class="hl-comment">// router.get('/users/:id/posts/:pid/comments/:cid/likes', ...);

"hl-keyword">export "hl-keyword">default router;
                                        

API Versioning

Never break consumers. Introduce versioning from day one.

URL versioning (most common): /v1/users, /v2/users Header versioning: Accept: application/vnd.api.v2+json Query param: /users?version=2 (least preferred)

versioning.ts
typescript
                                            "hl-keyword">class="hl-comment">// URL-based versioning — recommended "hl-keyword">for public APIs
"hl-keyword">import { Application } "hl-keyword">from 'express';
"hl-keyword">import v1Routes "hl-keyword">from './routes/v1';
"hl-keyword">import v2Routes "hl-keyword">from './routes/v2';

"hl-keyword">export "hl-keyword">function mountRoutes(app: Application) {
  app.use('/api/v1', v1Routes);
  app.use('/api/v2', v2Routes);

  "hl-keyword">class="hl-comment">// Deprecated header warning "hl-keyword">for v1
  app.use('/api/v1', (_req, res, next) => {
    res.setHeader(
      'Deprecation',
      'true; sunset="2026-01-01"'
    );
    res.setHeader(
      'Link',
      '</api/v2>; rel="successor-version"'
    );
    next();
  });
}
                                        

GraphQL vs REST

GraphQL lets clients request exactly the shape of data they need, eliminating over-fetching and under-fetching. The trade-off is complexity on the server.

Use REST when: Your API is public and has many diverse consumers Caching is critical (HTTP caching works out of the box)

Use GraphQL when: Client requirements vary wildly (mobile vs desktop) You're aggregating multiple microservices into one endpoint

schema.graphql
graphql
                                            "hl-keyword">type Query {
  user(id: ID!): User
  posts(filter: PostFilter, limit: Int = 20): [Post!]!
}

"hl-keyword">type Mutation {
  createPost(input: CreatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

"hl-keyword">type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!      "hl-keyword">class="hl-comment"># resolved lazily — only fetched when requested
  createdAt: DateTime!
}

"hl-keyword">type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  tags: [String!]!
  publishedAt: DateTime
}

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}
                                        

Rate Limiting & Error Contracts

Always rate-limit public endpoints and communicate limits via standard headers. Standardise error payloads so clients can parse them programmatically — RFC 7807 (Problem Details) is the industry standard.

errors.ts
typescript
                                            "hl-keyword">class="hl-comment">// RFC 7807 Problem Details
"hl-keyword">interface ProblemDetails {
  "hl-keyword">type: "hl-type">string;      "hl-keyword">class="hl-comment">// URI identifying error "hl-keyword">type
  title: "hl-type">string;     "hl-keyword">class="hl-comment">// short human-readable summary
  status: "hl-type">number;    "hl-keyword">class="hl-comment">// HTTP status code
  detail: "hl-type">string;    "hl-keyword">class="hl-comment">// human-readable explanation
  instance?: "hl-type">string; "hl-keyword">class="hl-comment">// URI "hl-keyword">of the specific occurrence
}

"hl-keyword">export "hl-keyword">function notFound(resource: "hl-type">string): ProblemDetails {
  "hl-keyword">return {
    "hl-keyword">type: 'https:class="hl-comment">//api.thunderclap.com/errors/not-found',
    title: 'Resource Not Found',
    status: 404,
    detail: `The requested ${resource} could not be found.`,
  };
}

"hl-keyword">export "hl-keyword">function validationError(fields: Record<"hl-type">string, "hl-type">string>): ProblemDetails {
  "hl-keyword">return {
    "hl-keyword">type: 'https:class="hl-comment">//api.thunderclap.com/errors/validation',
    title: 'Validation Failed',
    status: 422,
    detail: JSON.stringify(fields),
  };
}
                                        

💬 Comments

0 comments

Leave a comment

0/1000

Comments are moderated. Be respectful. ✌️

📚 Related Articles