API Design Patterns for Modern Applications

Modern API design has evolved far beyond simple CRUD operations. Today’s applications require APIs that are resilient, scalable, and developer-friendly while supporting diverse client needs and complex business workflows. This guide explores proven patterns that address these challenges.

Foundational Design Principles

API-First Development

Design your API before implementation to ensure consistency and usability:

// Define API contract first
interface UserAPI {
  // Resource operations
  getUser(id: string): Promise<User>;
  updateUser(id: string, updates: Partial<User>): Promise<User>;
  deleteUser(id: string): Promise<void>;
  
  // Collection operations
  listUsers(filters: UserFilters, pagination: Pagination): Promise<PagedResult<User>>;
  searchUsers(query: SearchQuery): Promise<SearchResult<User>>;
  
  // Business operations
  activateUser(id: string): Promise<User>;
  deactivateUser(id: string): Promise<User>;
  resetUserPassword(id: string): Promise<void>;
}

// OpenAPI specification (generated or hand-written)
const userAPISpec = {
  openapi: '3.0.0',
  info: {
    title: 'User Management API',
    version: '1.0.0'
  },
  paths: {
    '/users/{id}': {
      get: {
        summary: 'Get user by ID',
        parameters: [
          {
            name: 'id',
            in: 'path',
            required: true,
            schema: { type: 'string', format: 'uuid' }
          }
        ],
        responses: {
          200: {
            description: 'User found',
            content: {
              'application/json': {
                schema: { $ref: '#/components/schemas/User' }
              }
            }
          },
          404: {
            description: 'User not found',
            content: {
              'application/json': {
                schema: { $ref: '#/components/schemas/Error' }
              }
            }
          }
        }
      }
    }
  }
};

Resource-Oriented Design

Structure APIs around resources, not actions:

// Good: Resource-oriented endpoints
interface OrderAPI {
  // Orders resource
  createOrder(order: CreateOrderRequest): Promise<Order>;
  getOrder(orderId: string): Promise<Order>;
  updateOrder(orderId: string, updates: UpdateOrderRequest): Promise<Order>;
  cancelOrder(orderId: string): Promise<Order>;
  
  // Order items as sub-resource
  addOrderItem(orderId: string, item: OrderItem): Promise<OrderItem>;
  updateOrderItem(orderId: string, itemId: string, updates: Partial<OrderItem>): Promise<OrderItem>;
  removeOrderItem(orderId: string, itemId: string): Promise<void>;
  
  // Order status as resource state
  getOrderStatus(orderId: string): Promise<OrderStatus>;
  updateOrderStatus(orderId: string, status: OrderStatusUpdate): Promise<OrderStatus>;
}

// Avoid: Action-oriented endpoints
interface BadOrderAPI {
  processOrder(data: any): Promise<any>;
  doOrderCalculation(data: any): Promise<any>;
  performOrderValidation(data: any): Promise<any>;
}

Advanced REST Patterns

Hypermedia and HATEOAS

Include navigation links to make APIs self-discoverable:

interface HypermediaResource {
  data: any;
  links: {
    self: { href: string };
    [relationship: string]: { href: string; method?: string };
  };
  meta?: {
    total?: number;
    page?: number;
    lastUpdated?: string;
  };
}

class OrderController {
  async getOrder(req: Request, res: Response): Promise<void> {
    const order = await this.orderService.findById(req.params.id);
    
    const response: HypermediaResource = {
      data: order,
      links: {
        self: { href: `/orders/${order.id}` },
        items: { href: `/orders/${order.id}/items` },
        customer: { href: `/customers/${order.customerId}` },
        ...(order.status === 'pending' && {
          cancel: { href: `/orders/${order.id}/cancel`, method: 'POST' },
          update: { href: `/orders/${order.id}`, method: 'PATCH' }
        }),
        ...(order.status === 'confirmed' && {
          ship: { href: `/orders/${order.id}/ship`, method: 'POST' },
          track: { href: `/orders/${order.id}/tracking` }
        })
      },
      meta: {
        lastUpdated: order.updatedAt.toISOString()
      }
    };
    
    res.json(response);
  }
}

Advanced Query Patterns

Implement flexible querying capabilities:

// Query builder for complex filtering
interface QueryBuilder {
  filter(field: string, operator: FilterOperator, value: any): QueryBuilder;
  sort(field: string, direction: 'asc' | 'desc'): QueryBuilder;
  include(relationships: string[]): QueryBuilder;
  page(number: number, size: number): QueryBuilder;
  fields(fieldList: string[]): QueryBuilder;
  build(): QueryParameters;
}

// Usage example
class UserController {
  async listUsers(req: Request, res: Response): Promise<void> {
    const query = new QueryBuilder()
      .filter('status', 'eq', 'active')
      .filter('lastLogin', 'gte', new Date('2023-01-01'))
      .sort('createdAt', 'desc')
      .include(['profile', 'preferences'])
      .page(req.query.page || 1, req.query.size || 20)
      .fields(['id', 'name', 'email', 'status'])
      .build();
    
    const result = await this.userService.findMany(query);
    
    res.json({
      data: result.items,
      pagination: {
        page: result.page,
        size: result.size,
        total: result.total,
        totalPages: Math.ceil(result.total / result.size)
      },
      links: {
        self: req.originalUrl,
        next: result.hasNext ? this.buildNextPageUrl(req, result.page + 1) : null,
        prev: result.hasPrev ? this.buildPrevPageUrl(req, result.page - 1) : null
      }
    });
  }
}

// Advanced search with full-text capabilities
interface SearchAPI {
  search(query: {
    q: string;                    // Full-text search
    filters?: Record<string, any>; // Structured filters
    facets?: string[];            // Aggregation facets
    highlight?: boolean;          // Highlight matches
    fuzzy?: boolean;             // Fuzzy matching
  }): Promise<SearchResult>;
}

Content Negotiation and Versioning

Handle multiple API versions and content types:

class ContentNegotiationMiddleware {
  static handle() {
    return (req: Request, res: Response, next: NextFunction) => {
      // API version negotiation
      const apiVersion = this.getAPIVersion(req);
      req.apiVersion = apiVersion;
      
      // Content type negotiation
      const acceptHeader = req.headers.accept;
      const supportedTypes = ['application/json', 'application/xml', 'application/hal+json'];
      const preferredType = this.negotiateContentType(acceptHeader, supportedTypes);
      
      if (!preferredType) {
        return res.status(406).json({
          error: 'Not Acceptable',
          supportedTypes
        });
      }
      
      req.preferredContentType = preferredType;
      next();
    };
  }
  
  private static getAPIVersion(req: Request): string {
    // Version from header
    if (req.headers['api-version']) {
      return req.headers['api-version'] as string;
    }
    
    // Version from Accept header
    const acceptHeader = req.headers.accept;
    const versionMatch = acceptHeader?.match(/application\/vnd\.api\.v(\d+)\+json/);
    if (versionMatch) {
      return versionMatch[1];
    }
    
    // Version from URL
    const urlMatch = req.url.match(/\/v(\d+)\//);
    if (urlMatch) {
      return urlMatch[1];
    }
    
    return '1'; // Default version
  }
}

// Version-specific controllers
class UserControllerV1 {
  async getUser(req: Request, res: Response): Promise<void> {
    const user = await this.userService.findById(req.params.id);
    res.json(this.transformUserV1(user));
  }
  
  private transformUserV1(user: User): any {
    return {
      id: user.id,
      name: user.fullName, // V1 used 'name' instead of separate first/last
      email: user.email
    };
  }
}

class UserControllerV2 {
  async getUser(req: Request, res: Response): Promise<void> {
    const user = await this.userService.findById(req.params.id);
    res.json(this.transformUserV2(user));
  }
  
  private transformUserV2(user: User): any {
    return {
      id: user.id,
      firstName: user.firstName,
      lastName: user.lastName,
      email: user.email,
      profile: user.profile // V2 includes profile data
    };
  }
}

GraphQL Design Patterns

Schema-First GraphQL Design

// schema.graphql
type Query {
  user(id: ID!): User
  users(filter: UserFilter, pagination: PaginationInput): UserConnection
  searchUsers(query: String!): SearchResult
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload
  updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload
  deleteUser(id: ID!): DeleteUserPayload
}

type User {
  id: ID!
  firstName: String!
  lastName: String!
  email: String!
  profile: UserProfile
  orders(first: Int, after: String): OrderConnection
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

input UserFilter {
  status: UserStatus
  createdAfter: DateTime
  searchTerm: String
}

// Resolver implementation
class UserResolver {
  @Query()
  async user(@Arg('id') id: string): Promise<User> {
    return await this.userService.findById(id);
  }
  
  @Query()
  async users(
    @Arg('filter', { nullable: true }) filter: UserFilter,
    @Arg('pagination', { nullable: true }) pagination: PaginationInput
  ): Promise<UserConnection> {
    const result = await this.userService.findMany(filter, pagination);
    return this.transformToConnection(result);
  }
  
  @FieldResolver()
  async orders(
    @Root() user: User,
    @Arg('first', { defaultValue: 10 }) first: number,
    @Arg('after', { nullable: true }) after: string
  ): Promise<OrderConnection> {
    return await this.orderService.findByUserId(user.id, { first, after });
  }
}

DataLoader for N+1 Problem

import DataLoader from 'dataloader';

class DataLoaderFactory {
  createUserLoader(): DataLoader<string, User> {
    return new DataLoader<string, User>(
      async (userIds: readonly string[]) => {
        const users = await this.userService.findByIds([...userIds]);
        const userMap = new Map(users.map(user => [user.id, user]));
        return userIds.map(id => userMap.get(id) || new Error(`User not found: ${id}`));
      },
      {
        batch: true,
        cache: true,
        maxBatchSize: 100
      }
    );
  }
  
  createOrdersByUserLoader(): DataLoader<string, Order[]> {
    return new DataLoader<string, Order[]>(
      async (userIds: readonly string[]) => {
        const orders = await this.orderService.findByUserIds([...userIds]);
        const ordersByUser = new Map<string, Order[]>();
        
        orders.forEach(order => {
          const userOrders = ordersByUser.get(order.userId) || [];
          userOrders.push(order);
          ordersByUser.set(order.userId, userOrders);
        });
        
        return userIds.map(userId => ordersByUser.get(userId) || []);
      }
    );
  }
}

// Usage in resolver
class OrderResolver {
  @FieldResolver()
  async user(@Root() order: Order, @Ctx() context: GraphQLContext): Promise<User> {
    return await context.loaders.user.load(order.userId);
  }
}

Error Handling Patterns

Structured Error Responses

interface APIError {
  code: string;
  message: string;
  details?: Record<string, any>;
  timestamp: string;
  requestId: string;
  path: string;
}

interface ValidationError extends APIError {
  code: 'VALIDATION_ERROR';
  fieldErrors: FieldError[];
}

interface FieldError {
  field: string;
  message: string;
  rejectedValue?: any;
}

class ErrorHandler {
  static handleAPIError(error: Error, req: Request, res: Response): void {
    const requestId = req.headers['x-request-id'] as string || generateRequestId();
    
    if (error instanceof ValidationError) {
      res.status(400).json({
        code: 'VALIDATION_ERROR',
        message: 'Request validation failed',
        fieldErrors: error.fieldErrors,
        timestamp: new Date().toISOString(),
        requestId,
        path: req.path
      });
    } else if (error instanceof NotFoundError) {
      res.status(404).json({
        code: 'RESOURCE_NOT_FOUND',
        message: error.message,
        timestamp: new Date().toISOString(),
        requestId,
        path: req.path
      });
    } else if (error instanceof BusinessRuleError) {
      res.status(422).json({
        code: 'BUSINESS_RULE_VIOLATION',
        message: error.message,
        details: error.details,
        timestamp: new Date().toISOString(),
        requestId,
        path: req.path
      });
    } else {
      // Internal server error
      res.status(500).json({
        code: 'INTERNAL_ERROR',
        message: 'An unexpected error occurred',
        timestamp: new Date().toISOString(),
        requestId,
        path: req.path
      });
    }
  }
}

Rate Limiting and Throttling

Advanced Rate Limiting Strategies

interface RateLimitStrategy {
  isAllowed(key: string, request: Request): Promise<RateLimitResult>;
}

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetTime: Date;
  retryAfter?: number;
}

class TieredRateLimiter implements RateLimitStrategy {
  constructor(
    private redis: RedisClient,
    private tiers: RateLimitTier[]
  ) {}
  
  async isAllowed(key: string, request: Request): Promise<RateLimitResult> {
    const userTier = await this.getUserTier(request);
    const limits = this.tiers.find(t => t.name === userTier) || this.tiers[0];
    
    const windowStart = Math.floor(Date.now() / limits.windowMs) * limits.windowMs;
    const windowKey = `rate_limit:${key}:${windowStart}`;
    
    const current = await this.redis.incr(windowKey);
    if (current === 1) {
      await this.redis.expire(windowKey, Math.ceil(limits.windowMs / 1000));
    }
    
    const allowed = current <= limits.maxRequests;
    const resetTime = new Date(windowStart + limits.windowMs);
    
    return {
      allowed,
      remaining: Math.max(0, limits.maxRequests - current),
      resetTime,
      retryAfter: allowed ? undefined : Math.ceil((resetTime.getTime() - Date.now()) / 1000)
    };
  }
  
  private async getUserTier(request: Request): Promise<string> {
    const user = request.user;
    if (!user) return 'anonymous';
    
    // Check user subscription tier
    const subscription = await this.subscriptionService.getActiveSubscription(user.id);
    return subscription?.tier || 'basic';
  }
}

interface RateLimitTier {
  name: string;
  maxRequests: number;
  windowMs: number;
}

const rateLimitTiers: RateLimitTier[] = [
  { name: 'anonymous', maxRequests: 100, windowMs: 15 * 60 * 1000 }, // 100/15min
  { name: 'basic', maxRequests: 1000, windowMs: 15 * 60 * 1000 },    // 1000/15min
  { name: 'premium', maxRequests: 5000, windowMs: 15 * 60 * 1000 },  // 5000/15min
  { name: 'enterprise', maxRequests: 10000, windowMs: 15 * 60 * 1000 } // 10000/15min
];

Documentation and Developer Experience

Interactive API Documentation

// Swagger/OpenAPI with rich examples
const apiDocumentation = {
  openapi: '3.0.0',
  info: {
    title: 'Modern API',
    version: '1.0.0',
    description: 'Comprehensive API with rich examples and interactive documentation'
  },
  servers: [
    { url: 'https://api.example.com/v1', description: 'Production' },
    { url: 'https://staging-api.example.com/v1', description: 'Staging' },
    { url: 'http://localhost:3000/v1', description: 'Development' }
  ],
  paths: {
    '/users': {
      post: {
        summary: 'Create a new user',
        requestBody: {
          content: {
            'application/json': {
              schema: { $ref: '#/components/schemas/CreateUserRequest' },
              examples: {
                basic: {
                  summary: 'Basic user creation',
                  value: {
                    firstName: 'John',
                    lastName: 'Doe',
                    email: 'john.doe@example.com'
                  }
                },
                withProfile: {
                  summary: 'User with profile information',
                  value: {
                    firstName: 'Jane',
                    lastName: 'Smith',
                    email: 'jane.smith@example.com',
                    profile: {
                      bio: 'Software engineer passionate about clean code',
                      location: 'San Francisco, CA',
                      website: 'https://janesmith.dev'
                    }
                  }
                }
              }
            }
          }
        },
        responses: {
          201: {
            description: 'User created successfully',
            content: {
              'application/json': {
                schema: { $ref: '#/components/schemas/User' },
                examples: {
                  created: {
                    summary: 'Newly created user',
                    value: {
                      id: 'user_1234567890',
                      firstName: 'John',
                      lastName: 'Doe',
                      email: 'john.doe@example.com',
                      createdAt: '2023-01-15T10:30:00Z',
                      status: 'active'
                    }
                  }
                }
              }
            },
            headers: {
              'Location': {
                description: 'URL of the created user',
                schema: { type: 'string' }
              }
            }
          }
        }
      }
    }
  }
};

// SDK generation for multiple languages
class SDKGenerator {
  generateTypeScript(spec: OpenAPISpec): string {
    // Generate TypeScript client code
    return `
      export class APIClient {
        constructor(private baseURL: string, private apiKey: string) {}
        
        async createUser(user: CreateUserRequest): Promise<User> {
          const response = await fetch(\`\${this.baseURL}/users\`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': \`Bearer \${this.apiKey}\`
            },
            body: JSON.stringify(user)
          });
          
          if (!response.ok) {
            throw new APIError(await response.json());
          }
          
          return await response.json();
        }
      }
    `;
  }
}

Conclusion

Modern API design requires balancing multiple concerns: performance, usability, maintainability, and evolution. The patterns explored here provide a foundation for building APIs that can grow with your application while providing excellent developer experience.

Key takeaways:

  • Design your API contract first, before implementation
  • Use consistent patterns for similar operations
  • Implement comprehensive error handling and rate limiting
  • Provide rich documentation with examples
  • Plan for versioning and evolution from the start

Next in this series, we’ll explore microservices communication patterns that build upon these API design principles to create resilient distributed systems.

Comments