tech intermediate ⭐ Featured

Building Scalable APIs with TypeScript and Node.js

Learn how to design and implement highly scalable, maintainable APIs using TypeScript, Node.js, and modern architectural patterns.

Daniele Latini
Daniele Latini
Cybersecurity Consultant
18 min read
Share:
Skip to content

Building scalable APIs requires careful consideration of architecture, performance, and maintainability. This guide explores best practices for creating robust backend systems using TypeScript and Node.js.

Project Architecture and Setup

Modern Project Structure

src/
├── controllers/          # Request handlers
├── middleware/          # Custom middleware
├── models/             # Data models and schemas  
├── routes/             # API route definitions
├── services/           # Business logic layer
├── utils/              # Utility functions
├── types/              # TypeScript type definitions
├── config/             # Configuration files
└── tests/              # Test files

TypeScript Configuration

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "paths": {
      "@/*": ["./src/*"],
      "@/types/*": ["./src/types/*"],
      "@/utils/*": ["./src/utils/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

Type-Safe API Design

Defining API Types

// src/types/api.types.ts
export interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: string;
  message?: string;
  pagination?: PaginationMeta;
}

export interface PaginationMeta {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
  hasNext: boolean;
  hasPrev: boolean;
}

export interface PaginationQuery {
  page?: number;
  limit?: number;
  sortBy?: string;
  sortOrder?: 'asc' | 'desc';
}

// User-related types
export interface User {
  id: string;
  email: string;
  username: string;
  firstName: string;
  lastName: string;
  role: UserRole;
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  MODERATOR = 'moderator'
}

export interface CreateUserDto {
  email: string;
  username: string;
  firstName: string;
  lastName: string;
  password: string;
}

export interface UpdateUserDto {
  firstName?: string;
  lastName?: string;
  username?: string;
}

Request Validation with Zod

// src/utils/validation.ts
import { z } from 'zod';
import { Request, Response, NextFunction } from 'express';

export const createUserSchema = z.object({
  body: z.object({
    email: z.string().email('Invalid email format'),
    username: z.string().min(3, 'Username must be at least 3 characters'),
    firstName: z.string().min(1, 'First name is required'),
    lastName: z.string().min(1, 'Last name is required'),
    password: z.string()
      .min(8, 'Password must be at least 8 characters')
      .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'Password must contain uppercase, lowercase, and number')
  })
});

export const updateUserSchema = z.object({
  body: z.object({
    firstName: z.string().min(1).optional(),
    lastName: z.string().min(1).optional(), 
    username: z.string().min(3).optional()
  }),
  params: z.object({
    id: z.string().uuid('Invalid user ID format')
  })
});

// Validation middleware
export const validate = (schema: z.ZodSchema) => {
  return (req: Request, res: Response, next: NextFunction) => {
    try {
      schema.parse({
        body: req.body,
        query: req.query,
        params: req.params
      });
      next();
    } catch (error) {
      if (error instanceof z.ZodError) {
        const errorMessages = error.errors.map(err => ({
          field: err.path.join('.'),
          message: err.message
        }));
        
        return res.status(400).json({
          success: false,
          error: 'Validation failed',
          details: errorMessages
        });
      }
      next(error);
    }
  };
};

Service Layer Architecture

Database Service with Type Safety

// src/services/database.service.ts
import { Pool, QueryResult } from 'pg';
import { User, CreateUserDto, UpdateUserDto, PaginationQuery, PaginationMeta } from '@/types/api.types';

export class DatabaseService {
  private pool: Pool;

  constructor(connectionString: string) {
    this.pool = new Pool({
      connectionString,
      max: 20,
      idleTimeoutMillis: 30000,
      connectionTimeoutMillis: 2000,
    });
  }

  async query<T = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
    const client = await this.pool.connect();
    try {
      const result = await client.query<T>(text, params);
      return result;
    } finally {
      client.release();
    }
  }

  async transaction<T>(callback: (query: typeof this.query) => Promise<T>): Promise<T> {
    const client = await this.pool.connect();
    try {
      await client.query('BEGIN');
      const result = await callback(client.query.bind(client));
      await client.query('COMMIT');
      return result;
    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

// User service with repository pattern
export class UserService {
  constructor(private db: DatabaseService) {}

  async createUser(userData: CreateUserDto): Promise<User> {
    const hashedPassword = await this.hashPassword(userData.password);
    
    const query = `
      INSERT INTO users (email, username, first_name, last_name, password_hash, role, is_active)
      VALUES ($1, $2, $3, $4, $5, $6, $7)
      RETURNING id, email, username, first_name, last_name, role, is_active, created_at, updated_at
    `;
    
    const values = [
      userData.email,
      userData.username,
      userData.firstName,
      userData.lastName,
      hashedPassword,
      'user',
      true
    ];

    const result = await this.db.query<User>(query, values);
    return this.mapDbUserToUser(result.rows[0]);
  }

  async getUserById(id: string): Promise<User | null> {
    const query = `
      SELECT id, email, username, first_name, last_name, role, is_active, created_at, updated_at
      FROM users 
      WHERE id = $1 AND is_active = true
    `;
    
    const result = await this.db.query<User>(query, [id]);
    return result.rows[0] ? this.mapDbUserToUser(result.rows[0]) : null;
  }

  async getUsers(pagination: PaginationQuery): Promise<{ users: User[]; meta: PaginationMeta }> {
    const { page = 1, limit = 10, sortBy = 'created_at', sortOrder = 'desc' } = pagination;
    const offset = (page - 1) * limit;

    // Count total users
    const countQuery = 'SELECT COUNT(*) FROM users WHERE is_active = true';
    const countResult = await this.db.query<{ count: string }>(countQuery);
    const total = parseInt(countResult.rows[0].count);

    // Get paginated users
    const query = `
      SELECT id, email, username, first_name, last_name, role, is_active, created_at, updated_at
      FROM users 
      WHERE is_active = true
      ORDER BY ${sortBy} ${sortOrder}
      LIMIT $1 OFFSET $2
    `;

    const result = await this.db.query<User>(query, [limit, offset]);
    const users = result.rows.map(this.mapDbUserToUser);

    const totalPages = Math.ceil(total / limit);
    const meta: PaginationMeta = {
      page,
      limit,
      total,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1
    };

    return { users, meta };
  }

  async updateUser(id: string, updateData: UpdateUserDto): Promise<User | null> {
    const setClauses = [];
    const values = [];
    let paramCount = 1;

    Object.entries(updateData).forEach(([key, value]) => {
      if (value !== undefined) {
        setClauses.push(`${this.camelToSnake(key)} = $${paramCount++}`);
        values.push(value);
      }
    });

    if (setClauses.length === 0) {
      throw new Error('No valid fields to update');
    }

    values.push(id); // Add ID as last parameter
    
    const query = `
      UPDATE users 
      SET ${setClauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
      WHERE id = $${paramCount} AND is_active = true
      RETURNING id, email, username, first_name, last_name, role, is_active, created_at, updated_at
    `;

    const result = await this.db.query<User>(query, values);
    return result.rows[0] ? this.mapDbUserToUser(result.rows[0]) : null;
  }

  private mapDbUserToUser(dbUser: any): User {
    return {
      id: dbUser.id,
      email: dbUser.email,
      username: dbUser.username,
      firstName: dbUser.first_name,
      lastName: dbUser.last_name,
      role: dbUser.role,
      isActive: dbUser.is_active,
      createdAt: dbUser.created_at,
      updatedAt: dbUser.updated_at
    };
  }

  private camelToSnake(str: string): string {
    return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
  }

  private async hashPassword(password: string): Promise<string> {
    const bcrypt = require('bcrypt');
    return await bcrypt.hash(password, 12);
  }
}

Caching Layer with Redis

// src/services/cache.service.ts
import Redis from 'ioredis';

export class CacheService {
  private redis: Redis;
  private defaultTTL = 3600; // 1 hour

  constructor(connectionString: string) {
    this.redis = new Redis(connectionString, {
      retryDelayOnFailover: 100,
      enableReadyCheck: true,
      maxRetriesPerRequest: 3,
    });
  }

  async get<T>(key: string): Promise<T | null> {
    try {
      const value = await this.redis.get(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error('Cache get error:', error);
      return null;
    }
  }

  async set(key: string, value: any, ttl = this.defaultTTL): Promise<boolean> {
    try {
      await this.redis.setex(key, ttl, JSON.stringify(value));
      return true;
    } catch (error) {
      console.error('Cache set error:', error);
      return false;
    }
  }

  async delete(key: string): Promise<boolean> {
    try {
      await this.redis.del(key);
      return true;
    } catch (error) {
      console.error('Cache delete error:', error);
      return false;
    }
  }

  async invalidatePattern(pattern: string): Promise<boolean> {
    try {
      const keys = await this.redis.keys(pattern);
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
      return true;
    } catch (error) {
      console.error('Cache invalidate error:', error);
      return false;
    }
  }

  // Cache decorator for methods
  cache(ttl = this.defaultTTL) {
    return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
      const originalMethod = descriptor.value;
      
      descriptor.value = async function (...args: any[]) {
        const cacheKey = `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`;
        
        // Try to get from cache
        const cached = await this.cacheService?.get(cacheKey);
        if (cached !== null) {
          return cached;
        }

        // Execute original method
        const result = await originalMethod.apply(this, args);
        
        // Cache the result
        if (this.cacheService) {
          await this.cacheService.set(cacheKey, result, ttl);
        }
        
        return result;
      };
    };
  }
}

Controller Layer with Error Handling

// src/controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '@/services/user.service';
import { CacheService } from '@/services/cache.service';
import { ApiResponse, User, CreateUserDto, UpdateUserDto, PaginationQuery } from '@/types/api.types';

export class UserController {
  constructor(
    private userService: UserService,
    private cacheService: CacheService
  ) {}

  createUser = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const userData: CreateUserDto = req.body;
      const user = await this.userService.createUser(userData);
      
      // Invalidate users list cache
      await this.cacheService.invalidatePattern('users:list:*');
      
      const response: ApiResponse<User> = {
        success: true,
        data: user,
        message: 'User created successfully'
      };
      
      res.status(201).json(response);
    } catch (error) {
      next(error);
    }
  };

  getUser = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const { id } = req.params;
      
      // Try cache first
      const cacheKey = `user:${id}`;
      let user = await this.cacheService.get<User>(cacheKey);
      
      if (!user) {
        user = await this.userService.getUserById(id);
        if (user) {
          await this.cacheService.set(cacheKey, user, 1800); // 30 minutes
        }
      }

      if (!user) {
        const response: ApiResponse = {
          success: false,
          error: 'User not found'
        };
        res.status(404).json(response);
        return;
      }

      const response: ApiResponse<User> = {
        success: true,
        data: user
      };
      
      res.json(response);
    } catch (error) {
      next(error);
    }
  };

  getUsers = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const pagination: PaginationQuery = {
        page: parseInt(req.query.page as string) || 1,
        limit: parseInt(req.query.limit as string) || 10,
        sortBy: req.query.sortBy as string || 'created_at',
        sortOrder: req.query.sortOrder as 'asc' | 'desc' || 'desc'
      };

      const cacheKey = `users:list:${JSON.stringify(pagination)}`;
      let result = await this.cacheService.get<{ users: User[]; meta: any }>(cacheKey);

      if (!result) {
        result = await this.userService.getUsers(pagination);
        await this.cacheService.set(cacheKey, result, 600); // 10 minutes
      }

      const response: ApiResponse<User[]> = {
        success: true,
        data: result.users,
        pagination: result.meta
      };
      
      res.json(response);
    } catch (error) {
      next(error);
    }
  };

  updateUser = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const { id } = req.params;
      const updateData: UpdateUserDto = req.body;
      
      const user = await this.userService.updateUser(id, updateData);
      
      if (!user) {
        const response: ApiResponse = {
          success: false,
          error: 'User not found'
        };
        res.status(404).json(response);
        return;
      }

      // Invalidate cache
      await this.cacheService.delete(`user:${id}`);
      await this.cacheService.invalidatePattern('users:list:*');

      const response: ApiResponse<User> = {
        success: true,
        data: user,
        message: 'User updated successfully'
      };
      
      res.json(response);
    } catch (error) {
      next(error);
    }
  };
}

Performance and Monitoring

Rate Limiting and Security

// src/middleware/rateLimiter.ts
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export const createRateLimiter = (windowMs: number, max: number) => {
  return rateLimit({
    store: new RedisStore({
      sendCommand: (...args: string[]) => redis.call(...args),
    }),
    windowMs,
    max,
    message: {
      success: false,
      error: 'Too many requests, please try again later'
    },
    standardHeaders: true,
    legacyHeaders: false,
  });
};

// Different limits for different endpoints
export const authLimiter = createRateLimiter(15 * 60 * 1000, 5); // 5 requests per 15 minutes
export const apiLimiter = createRateLimiter(15 * 60 * 1000, 100); // 100 requests per 15 minutes

Application Monitoring

// src/middleware/monitoring.ts
import { Request, Response, NextFunction } from 'express';
import { performance } from 'perf_hooks';

interface RequestMetrics {
  method: string;
  url: string;
  statusCode: number;
  responseTime: number;
  timestamp: Date;
}

export class MonitoringService {
  private metrics: RequestMetrics[] = [];

  requestLogger = (req: Request, res: Response, next: NextFunction): void => {
    const startTime = performance.now();

    res.on('finish', () => {
      const endTime = performance.now();
      const responseTime = endTime - startTime;

      const metric: RequestMetrics = {
        method: req.method,
        url: req.originalUrl,
        statusCode: res.statusCode,
        responseTime,
        timestamp: new Date()
      };

      this.metrics.push(metric);
      
      // Log slow requests
      if (responseTime > 1000) {
        console.warn(`Slow request detected: ${req.method} ${req.originalUrl} - ${responseTime.toFixed(2)}ms`);
      }

      // Keep only last 1000 metrics in memory
      if (this.metrics.length > 1000) {
        this.metrics = this.metrics.slice(-1000);
      }
    });

    next();
  };

  getMetrics(): RequestMetrics[] {
    return this.metrics;
  }

  getAverageResponseTime(): number {
    if (this.metrics.length === 0) return 0;
    
    const total = this.metrics.reduce((sum, metric) => sum + metric.responseTime, 0);
    return total / this.metrics.length;
  }
}

Conclusion

Building scalable APIs with TypeScript and Node.js requires:

  1. Strong Type Safety: Use TypeScript effectively with proper type definitions
  2. Layered Architecture: Separate concerns with controllers, services, and data layers
  3. Caching Strategy: Implement Redis caching for improved performance
  4. Error Handling: Comprehensive error handling and validation
  5. Monitoring: Track performance metrics and identify bottlenecks
  6. Security: Implement rate limiting, input validation, and security headers

This architecture provides a solid foundation for APIs that can scale from thousands to millions of requests while maintaining code quality and developer productivity.