API Design Best Practices: Building Elegant RESTful Interfaces

Master RESTful API design principles, versioning, error handling, and documentation standards

API Design Best Practices: Building Elegant RESTful Interfaces

Excellent API design is key to building maintainable and scalable systems. This article explores RESTful API design principles and best practices.

RESTful Design Principles

Resource-Oriented Design

RESTful API Design Approach:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Core Concepts                                     │
│   ├── Resource: Entities in the system             │
│   ├── Representation: JSON/XML form of resources   │
│   ├── State Transfer: Via HTTP methods             │
│   └── Uniform Interface: Standardized operations   │
│                                                     │
│   ✅ Good Design                                     │
│   GET    /users          Get user list             │
│   POST   /users          Create user               │
│   GET    /users/123      Get specific user         │
│   PUT    /users/123      Update user               │
│   DELETE /users/123      Delete user               │
│                                                     │
│   ❌ Avoid                                           │
│   GET    /getUsers                                  │
│   POST   /createUser                                │
│   POST   /deleteUser?id=123                         │
│                                                     │
└─────────────────────────────────────────────────────┘

HTTP Method Semantics

MethodIdempotentSafePurpose
GETYesYesRetrieve resource
POSTNoNoCreate resource
PUTYesNoFull update
PATCHNoNoPartial update
DELETEYesNoDelete resource

URL Design Standards

// URL Design Best Practices

// 1. Use plural nouns for collections
GET /api/v1/users
GET /api/v1/products

// 2. Use slashes for hierarchy
GET /api/v1/users/123/orders
GET /api/v1/organizations/456/members

// 3. Use query params for filtering, sorting, pagination
GET /api/v1/products?category=electronics&sort=-price&page=1&limit=20

// 4. Avoid deep nesting (more than 3 levels)
// ❌ Not recommended
GET /api/v1/countries/1/states/2/cities/3/districts/4

// ✅ Recommended: use query params
GET /api/v1/districts?city=3

// 5. Use hyphens to separate words
GET /api/v1/user-profiles
GET /api/v1/order-items

// 6. Lowercase letters
// ❌ /api/v1/userProfiles
// ✅ /api/v1/user-profiles

Request and Response Design

Request Body Design

// Create resource
// POST /api/v1/users
interface CreateUserRequest {
  email: string;
  password: string;
  profile: {
    firstName: string;
    lastName: string;
    avatar?: string;
  };
  preferences?: {
    language: string;
    timezone: string;
    notifications: boolean;
  };
}

// Update resource - PUT (full update)
// PUT /api/v1/users/123
interface UpdateUserRequest {
  email: string;
  profile: {
    firstName: string;
    lastName: string;
    avatar: string | null;
  };
  preferences: {
    language: string;
    timezone: string;
    notifications: boolean;
  };
}

// Partial update - PATCH
// PATCH /api/v1/users/123
interface PatchUserRequest {
  email?: string;
  profile?: {
    firstName?: string;
    lastName?: string;
  };
}

Response Body Design

// Single resource response
interface UserResponse {
  id: string;
  email: string;
  profile: {
    firstName: string;
    lastName: string;
    avatar: string | null;
  };
  createdAt: string;  // ISO 8601 format
  updatedAt: string;
  links: {
    self: string;
    orders: string;
  };
}

// Collection response (with pagination)
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
  links: {
    self: string;
    first: string;
    prev: string | null;
    next: string | null;
    last: string;
  };
}

// Example response
const response: PaginatedResponse<UserResponse> = {
  data: [
    { id: '1', email: 'alice@example.com', /* ... */ },
    { id: '2', email: 'bob@example.com', /* ... */ },
  ],
  pagination: {
    page: 1,
    limit: 20,
    total: 156,
    totalPages: 8,
  },
  links: {
    self: '/api/v1/users?page=1&limit=20',
    first: '/api/v1/users?page=1&limit=20',
    prev: null,
    next: '/api/v1/users?page=2&limit=20',
    last: '/api/v1/users?page=8&limit=20',
  },
};

HTTP Status Codes

// Success codes
// 200 OK - Request successful
// 201 Created - Resource created
// 204 No Content - Deletion successful, no content

// Client errors
// 400 Bad Request - Malformed request
// 401 Unauthorized - Not authenticated
// 403 Forbidden - No permission
// 404 Not Found - Resource doesn't exist
// 409 Conflict - Resource conflict
// 422 Unprocessable Entity - Validation failed
// 429 Too Many Requests - Rate limited

// Server errors
// 500 Internal Server Error
// 502 Bad Gateway
// 503 Service Unavailable
// 504 Gateway Timeout

// Express example
app.post('/api/v1/users', async (req, res) => {
  try {
    const user = await userService.create(req.body);
    res.status(201).json(user);
  } catch (error) {
    if (error instanceof ValidationError) {
      res.status(422).json({ error: error.message, details: error.errors });
    } else if (error instanceof DuplicateError) {
      res.status(409).json({ error: 'Email already exists' });
    } else {
      res.status(500).json({ error: 'Internal server error' });
    }
  }
});

Error Handling

Error Response Format

// Standard error response
interface ErrorResponse {
  error: {
    code: string;           // Machine-readable error code
    message: string;        // Human-readable message
    details?: ErrorDetail[]; // Detailed error info
    requestId?: string;     // Request tracking ID
    documentation?: string; // Documentation link
  };
}

interface ErrorDetail {
  field: string;
  code: string;
  message: string;
}

// Validation error example
const validationError: ErrorResponse = {
  error: {
    code: 'VALIDATION_ERROR',
    message: 'Request validation failed',
    details: [
      {
        field: 'email',
        code: 'INVALID_FORMAT',
        message: 'Email format is invalid',
      },
      {
        field: 'password',
        code: 'TOO_SHORT',
        message: 'Password must be at least 8 characters',
      },
    ],
    requestId: 'req_abc123',
    documentation: 'https://api.example.com/docs/errors#VALIDATION_ERROR',
  },
};

Error Handling Middleware

// Custom error classes
class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: ErrorDetail[]
  ) {
    super(message);
  }
}

class ValidationError extends ApiError {
  constructor(details: ErrorDetail[]) {
    super(422, 'VALIDATION_ERROR', 'Request validation failed', details);
  }
}

class NotFoundError extends ApiError {
  constructor(resource: string) {
    super(404, 'NOT_FOUND', `${resource} not found`);
  }
}

class UnauthorizedError extends ApiError {
  constructor() {
    super(401, 'UNAUTHORIZED', 'Authentication required');
  }
}

// Global error handling middleware
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  const requestId = req.headers['x-request-id'] || generateRequestId();

  if (err instanceof ApiError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
        requestId,
      },
    });
  }

  // Log unknown errors
  logger.error({ err, requestId }, 'Unhandled error');

  res.status(500).json({
    error: {
      code: 'INTERNAL_ERROR',
      message: 'An unexpected error occurred',
      requestId,
    },
  });
};

app.use(errorHandler);

Versioning

Version Strategies

// 1. URL versioning (recommended)
// Clear, intuitive, easy to cache
GET /api/v1/users
GET /api/v2/users

// 2. Header versioning
// Keeps URLs clean
GET /api/users
Accept: application/vnd.api+json; version=1

// 3. Query parameter versioning
// Flexible but not recommended
GET /api/users?version=1

// Express URL versioning implementation
import { Router } from 'express';

const v1Router = Router();
const v2Router = Router();

// V1 API
v1Router.get('/users', (req, res) => {
  // V1 implementation
});

// V2 API - new fields
v2Router.get('/users', (req, res) => {
  // V2 implementation with new fields
});

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

Backward Compatibility

// Adding new fields is safe (backward compatible)
interface UserV1 {
  id: string;
  email: string;
  name: string;
}

interface UserV2 extends UserV1 {
  avatar: string | null;      // New field
  preferences: Preferences;   // New field
}

// Removing or renaming fields requires new version
interface UserV3 {
  id: string;
  email: string;
  firstName: string;  // Split from name
  lastName: string;   // Split from name
  avatar: string | null;
  preferences: Preferences;
}

// Version adapter
function toUserV1(user: UserV3): UserV1 {
  return {
    id: user.id,
    email: user.email,
    name: `${user.firstName} ${user.lastName}`,
  };
}

Authentication and Authorization

JWT Authentication

import jwt from 'jsonwebtoken';

// Generate tokens
function generateTokens(user: User) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    process.env.JWT_SECRET!,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { sub: user.id },
    process.env.REFRESH_SECRET!,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

// Authentication middleware
const authenticate = async (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    throw new UnauthorizedError();
  }

  const token = authHeader.substring(7);

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
    req.user = {
      id: payload.sub as string,
      email: payload.email,
      role: payload.role,
    };
    next();
  } catch (error) {
    if (error instanceof jwt.TokenExpiredError) {
      throw new ApiError(401, 'TOKEN_EXPIRED', 'Access token has expired');
    }
    throw new UnauthorizedError();
  }
};

// Authorization middleware
const authorize = (...roles: string[]) => {
  return (req: Request, res: Response, next: NextFunction) => {
    if (!req.user) {
      throw new UnauthorizedError();
    }

    if (!roles.includes(req.user.role)) {
      throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions');
    }

    next();
  };
};

// Usage example
app.get('/api/v1/admin/users', authenticate, authorize('admin'), async (req, res) => {
  const users = await userService.findAll();
  res.json({ data: users });
});

API Key Authentication

// API Key middleware
const apiKeyAuth = async (req: Request, res: Response, next: NextFunction) => {
  const apiKey = req.headers['x-api-key'] as string;

  if (!apiKey) {
    throw new ApiError(401, 'API_KEY_REQUIRED', 'API key is required');
  }

  const client = await apiKeyService.validate(apiKey);

  if (!client) {
    throw new ApiError(401, 'INVALID_API_KEY', 'Invalid API key');
  }

  req.client = client;
  next();
};

Rate Limiting

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

// Basic rate limiting
const basicLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  message: {
    error: {
      code: 'RATE_LIMITED',
      message: 'Too many requests, please try again later',
    },
  },
  standardHeaders: true, // Return RateLimit-* headers
  legacyHeaders: false,
});

// Redis store (distributed)
const redisLimiter = rateLimit({
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
  windowMs: 60 * 1000,
  max: 100,
});

// Tiered rate limiting
const tierLimits = {
  free: { windowMs: 60000, max: 10 },
  basic: { windowMs: 60000, max: 100 },
  pro: { windowMs: 60000, max: 1000 },
  enterprise: { windowMs: 60000, max: 10000 },
};

const tieredLimiter = (req: Request, res: Response, next: NextFunction) => {
  const tier = req.client?.tier || 'free';
  const limits = tierLimits[tier];

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    keyGenerator: (req) => req.client?.id || req.ip,
  })(req, res, next);
};

// Response headers
// RateLimit-Limit: 100
// RateLimit-Remaining: 95
// RateLimit-Reset: 1640000000

Querying and Filtering

Filtering, Sorting, Pagination

// GET /api/v1/products?category=electronics&minPrice=100&maxPrice=500&sort=-price,name&page=1&limit=20

interface QueryParams {
  // Filtering
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  status?: string;

  // Sorting (- means descending)
  sort?: string;

  // Pagination
  page?: number;
  limit?: number;
}

function parseQueryParams(query: any): QueryParams {
  return {
    category: query.category,
    minPrice: query.minPrice ? Number(query.minPrice) : undefined,
    maxPrice: query.maxPrice ? Number(query.maxPrice) : undefined,
    status: query.status,
    sort: query.sort,
    page: query.page ? Number(query.page) : 1,
    limit: Math.min(Number(query.limit) || 20, 100), // Max 100
  };
}

function buildQuery(params: QueryParams) {
  const where: any = {};
  const orderBy: any[] = [];

  // Filter conditions
  if (params.category) {
    where.category = params.category;
  }

  if (params.minPrice !== undefined || params.maxPrice !== undefined) {
    where.price = {};
    if (params.minPrice !== undefined) where.price.gte = params.minPrice;
    if (params.maxPrice !== undefined) where.price.lte = params.maxPrice;
  }

  // Sorting
  if (params.sort) {
    const fields = params.sort.split(',');
    for (const field of fields) {
      const desc = field.startsWith('-');
      const name = desc ? field.slice(1) : field;
      orderBy.push({ [name]: desc ? 'desc' : 'asc' });
    }
  }

  return {
    where,
    orderBy,
    skip: (params.page! - 1) * params.limit!,
    take: params.limit,
  };
}

Field Selection

// GET /api/v1/users?fields=id,name,email

app.get('/api/v1/users', async (req, res) => {
  const fields = req.query.fields?.split(',') || null;

  const users = await prisma.user.findMany({
    select: fields
      ? fields.reduce((acc, field) => ({ ...acc, [field]: true }), {})
      : undefined,
  });

  res.json({ data: users });
});
// GET /api/v1/users/123?include=orders,profile

app.get('/api/v1/users/:id', async (req, res) => {
  const includes = req.query.include?.split(',') || [];

  const user = await prisma.user.findUnique({
    where: { id: req.params.id },
    include: {
      orders: includes.includes('orders'),
      profile: includes.includes('profile'),
    },
  });

  res.json({ data: user });
});

API Documentation

OpenAPI Specification

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0
  description: User management API

servers:
  - url: https://api.example.com/v1
    description: Production server

paths:
  /users:
    get:
      summary: List users
      tags: [Users]
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 100
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserListResponse'

    post:
      summary: Create user
      tags: [Users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'
        '422':
          $ref: '#/components/responses/ValidationError'

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        name:
          type: string
        createdAt:
          type: string
          format: date-time
      required: [id, email, name, createdAt]

    CreateUserRequest:
      type: object
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          minLength: 8
        name:
          type: string
      required: [email, password, name]

  responses:
    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

security:
  - bearerAuth: []

Best Practices Summary

API Design Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Design Principles                                 │
│   ├── Resource-centric design                      │
│   ├── Use correct HTTP methods                     │
│   ├── Return appropriate status codes              │
│   └── Maintain consistency                         │
│                                                     │
│   Security                                          │
│   ├── Always use HTTPS                             │
│   ├── Implement authentication & authorization     │
│   ├── Rate limiting                                │
│   └── Input validation                             │
│                                                     │
│   Usability                                         │
│   ├── Version control                              │
│   ├── Comprehensive error messages                 │
│   ├── Good documentation                           │
│   └── Consistent response format                   │
│                                                     │
└─────────────────────────────────────────────────────┘
ScenarioRecommended Approach
Version managementURL path versioning (/v1/)
AuthenticationJWT + Refresh Token
Error formatUnified ErrorResponse
PaginationPage-based + optional cursor
Documentation toolOpenAPI/Swagger

Good API design is the foundation of user experience and development efficiency. Follow these principles to build clear, consistent, and easy-to-use APIs.


An API is your contract with developers. A well-designed API is the greatest respect you can show to your users.