Skip to content

Latest commit

 

History

History
516 lines (410 loc) · 12.5 KB

File metadata and controls

516 lines (410 loc) · 12.5 KB

API Design Patterns

🎯 Overview

Choosing the right API style is a common system design discussion. This guide covers REST, GraphQL, and gRPC — when to use each, trade-offs, and interview-ready talking points.


1️⃣ REST (Representational State Transfer)

Core Principles

  • Stateless — each request contains all info needed
  • Resource-based — URLs represent nouns, HTTP verbs represent actions
  • Uniform interface — standard HTTP methods
  • Cacheable — responses can be cached

HTTP Methods

Method Action Idempotent Safe
GET Read
POST Create
PUT Replace
PATCH Partial update
DELETE Delete

REST API Design Best Practices

# Resource naming — use nouns, not verbs
✅ GET /users/123
❌ GET /getUser?id=123

# Nested resources for relationships
GET /users/123/orders          # All orders for user 123
GET /users/123/orders/456      # Specific order

# Filtering, sorting, pagination via query params
GET /products?category=electronics&sort=price&order=asc&page=2&limit=20

# Versioning
GET /v1/users/123
GET /v2/users/123

# Status codes
200 OK           — Success
201 Created      — Resource created (POST)
204 No Content   — Success, no body (DELETE)
400 Bad Request  — Invalid input
401 Unauthorized — Not authenticated
403 Forbidden    — Authenticated but not authorized
404 Not Found    — Resource doesn't exist
409 Conflict     — Duplicate resource
422 Unprocessable — Validation error
429 Too Many Requests — Rate limited
500 Internal Server Error

REST Response Structure

// Standard response envelope
{
  "success": true,
  "data": {
    "id": "user_123",
    "name": "Rahul Sharma",
    "email": "rahul@example.com"
  },
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 150
  }
}

// Error response
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "details": [
      { "field": "email", "message": "Email cannot be empty" }
    ]
  }
}

REST Pros & Cons

✅ Pros ❌ Cons
Simple, widely understood Over-fetching (getting more data than needed)
Great tooling (Postman, Swagger) Under-fetching (multiple requests for related data)
HTTP caching built-in No real-time support natively
Stateless = easy to scale Versioning can get messy
Works everywhere N+1 query problem

2️⃣ GraphQL

Core Concepts

  • Single endpointPOST /graphql
  • Client specifies shape — ask for exactly what you need
  • Strongly typed schema — self-documenting
  • Queries (read), Mutations (write), Subscriptions (real-time)

Schema Definition

type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
  createdAt: String!
}

type Order {
  id: ID!
  total: Float!
  status: String!
  items: [OrderItem!]!
}

type OrderItem {
  product: Product!
  quantity: Int!
  price: Float!
}

type Query {
  user(id: ID!): User
  users(page: Int, limit: Int): [User!]!
  order(id: ID!): Order
}

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String): User!
  deleteUser(id: ID!): Boolean!
  placeOrder(userId: ID!, items: [OrderItemInput!]!): Order!
}

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

Query Examples

# Fetch only what you need (no over-fetching)
query GetUserWithOrders {
  user(id: "123") {
    name
    email
    orders {
      id
      total
      status
    }
  }
}

# Mutation
mutation CreateUser {
  createUser(name: "Priya", email: "priya@example.com") {
    id
    name
  }
}

# Real-time subscription
subscription TrackOrder {
  orderStatusChanged(orderId: "ORD-456") {
    id
    status
  }
}

Resolver Implementation

const resolvers = {
  Query: {
    user: async (_, { id }, { dataSources }) => {
      return dataSources.userAPI.getUser(id)
    },
    users: async (_, { page = 1, limit = 20 }, { dataSources }) => {
      return dataSources.userAPI.getUsers({ page, limit })
    }
  },

  Mutation: {
    createUser: async (_, { name, email }, { dataSources }) => {
      return dataSources.userAPI.createUser({ name, email })
    }
  },

  // Field-level resolver — runs when 'orders' field is requested
  User: {
    orders: async (user, _, { dataSources }) => {
      return dataSources.orderAPI.getOrdersByUser(user.id)
    }
  }
}

N+1 Problem & DataLoader

// ❌ Problem: 1 query for users + N queries for each user's orders
// If 100 users → 101 DB queries!

// ✅ Solution: DataLoader batches requests
const DataLoader = require('dataloader')

const orderLoader = new DataLoader(async (userIds) => {
  // One query for ALL users' orders
  const orders = await db.query(
    'SELECT * FROM orders WHERE user_id IN (?)',
    [userIds]
  )

  // Group by userId
  const ordersByUser = userIds.map(id =>
    orders.filter(o => o.user_id === id)
  )

  return ordersByUser
})

// Now User.orders resolver uses the loader
User: {
  orders: (user, _, { loaders }) => loaders.order.load(user.id)
}
// 100 users → 2 DB queries (1 for users, 1 batched for all orders)

GraphQL Pros & Cons

✅ Pros ❌ Cons
No over/under-fetching Complex to set up
Single endpoint N+1 problem (need DataLoader)
Self-documenting schema Harder to cache (POST requests)
Great for complex, nested data Overkill for simple CRUD
Real-time via subscriptions Learning curve

3️⃣ gRPC (Google Remote Procedure Call)

Core Concepts

  • Protocol Buffers — binary serialization (smaller, faster than JSON)
  • HTTP/2 — multiplexing, bidirectional streaming
  • Strongly typed.proto schema files
  • 4 communication patterns — unary, server streaming, client streaming, bidirectional

Proto Definition

syntax = "proto3";

package user;

service UserService {
  // Unary RPC
  rpc GetUser (GetUserRequest) returns (User);

  // Server streaming — server sends multiple responses
  rpc ListUsers (ListUsersRequest) returns (stream User);

  // Client streaming — client sends multiple requests
  rpc CreateUsers (stream CreateUserRequest) returns (CreateUsersResponse);

  // Bidirectional streaming
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

message GetUserRequest {
  string id = 1;
}

message ListUsersRequest {
  int32 page = 1;
  int32 limit = 2;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message CreateUsersResponse {
  repeated User users = 1;
  int32 created_count = 2;
}

gRPC Server (Node.js)

const grpc = require('@grpc/grpc-js')
const protoLoader = require('@grpc/proto-loader')

const packageDef = protoLoader.loadSync('user.proto')
const proto = grpc.loadPackageDefinition(packageDef).user

const server = new grpc.Server()

server.addService(proto.UserService.service, {
  getUser: async (call, callback) => {
    const { id } = call.request
    const user = await db.findUser(id)
    if (!user) {
      return callback({ code: grpc.status.NOT_FOUND, message: 'User not found' })
    }
    callback(null, user)
  },

  listUsers: async (call) => {
    const users = await db.getUsers(call.request)
    users.forEach(user => call.write(user))
    call.end()
  }
})

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  server.start()
})

gRPC Pros & Cons

✅ Pros ❌ Cons
Very fast (binary + HTTP/2) Not human-readable
Streaming support Limited browser support
Strong typing Steeper learning curve
Code generation Harder to debug
Great for microservices Overkill for public APIs

4️⃣ When to Use What

Decision Tree:

Is it a public API?
├── Yes → REST (universal support, easy to consume)
└── No (internal microservices)
    ├── Need real-time / streaming? → gRPC or GraphQL subscriptions
    ├── Complex nested data, mobile client? → GraphQL
    └── High performance, service-to-service? → gRPC

Is the client mobile?
├── Yes → GraphQL (reduce over-fetching, save bandwidth)
└── No → REST or gRPC

Need real-time?
├── Yes → WebSockets, gRPC streaming, or GraphQL subscriptions
└── No → REST or GraphQL queries

Comparison Table

REST GraphQL gRPC
Protocol HTTP/1.1 HTTP/1.1 HTTP/2
Format JSON JSON Protobuf (binary)
Schema OpenAPI (optional) Required Required (.proto)
Caching Easy (GET) Hard Hard
Real-time Polling/SSE Subscriptions Streaming
Browser support ⚠️ (grpc-web)
Performance Good Good Excellent
Best for Public APIs, CRUD Complex data, mobile Microservices, streaming

5️⃣ API Security

Authentication & Authorization

// JWT Authentication middleware
const jwt = require('jsonwebtoken')

function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1]

  if (!token) {
    return res.status(401).json({ error: 'No token provided' })
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET)
    req.user = decoded
    next()
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' })
  }
}

// Role-based authorization
function authorize(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' })
    }
    next()
  }
}

// Usage
app.get('/admin/users', authenticate, authorize('admin'), getUsers)
app.get('/profile', authenticate, getProfile)

Rate Limiting

const rateLimit = require('express-rate-limit')

// Global rate limit
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // 100 requests per window
  message: { error: 'Too many requests, please try again later' }
})

// Stricter limit for auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,  // Only 5 login attempts per 15 min
  message: { error: 'Too many login attempts' }
})

app.use('/api/', globalLimiter)
app.use('/api/auth/login', authLimiter)

Input Validation

const { body, validationResult } = require('express-validator')

const createUserValidation = [
  body('email').isEmail().normalizeEmail(),
  body('name').trim().isLength({ min: 2, max: 50 }),
  body('password').isLength({ min: 8 }).matches(/^(?=.*[A-Z])(?=.*[0-9])/),
]

app.post('/users', createUserValidation, (req, res) => {
  const errors = validationResult(req)
  if (!errors.isEmpty()) {
    return res.status(422).json({ errors: errors.array() })
  }
  // Proceed with creation
})

6️⃣ API Versioning Strategies

// Strategy 1: URL versioning (most common)
app.use('/v1', v1Router)
app.use('/v2', v2Router)
// GET /v1/users vs GET /v2/users

// Strategy 2: Header versioning
app.use((req, res, next) => {
  const version = req.headers['api-version'] || 'v1'
  req.apiVersion = version
  next()
})

// Strategy 3: Query param
// GET /users?version=2

// Strategy 4: Content negotiation
// Accept: application/vnd.myapi.v2+json

Recommendation: URL versioning for public APIs (most visible, easiest to test)


7️⃣ Interview Talking Points

"Why REST over GraphQL?"

REST is simpler, has better caching support, and is universally understood. For a CRUD API with predictable data shapes, REST is the right choice. GraphQL shines when clients have diverse data needs or when you want to avoid multiple round trips.

"Why gRPC for microservices?"

gRPC uses Protocol Buffers (binary, ~5x smaller than JSON) over HTTP/2 (multiplexed connections). For internal service-to-service calls doing thousands of RPC calls per second, this performance difference is significant. The strongly-typed schema also acts as a contract between services.

"How do you handle API versioning?"

I prefer URL versioning (/v1/, /v2/) for public APIs because it's explicit and easy to test. For internal APIs, I use header versioning to keep URLs clean. The key is maintaining backward compatibility — never remove fields, only add them.


🔗 Related Resources