Building Scalable APIs with Node.js and TypeScript
Introduction
In today's digital landscape, building scalable APIs is crucial for any successful application. As your user base grows, your backend needs to handle increasing loads while maintaining performance and reliability. In this comprehensive guide, we'll explore proven strategies for building robust, scalable APIs using Node.js and TypeScript.
Why Node.js and TypeScript?
Node.js provides excellent performance for I/O-intensive operations, making it ideal for API development. Its event-driven, non-blocking architecture allows handling thousands of concurrent connections efficiently.
- Better code quality and maintainability
- Enhanced developer experience with IntelliSense
- Early error detection during development
- Improved refactoring capabilities
Architecture Principles
1. Layered Architecture
Structure your application in distinct layers:
```typescript // Controller Layer export class UserController { constructor(private userService: UserService) {} async createUser(req: Request, res: Response) { try { const user = await this.userService.createUser(req.body); res.status(201).json(user); } catch (error) { res.status(400).json({ error: error.message }); } } }
// Service Layer export class UserService { constructor(private userRepository: UserRepository) {} async createUser(userData: CreateUserDto): Promise<User> { // Business logic here const hashedPassword = await bcrypt.hash(userData.password, 10); return this.userRepository.create({ ...userData, password: hashedPassword }); } }
// Repository Layer export class UserRepository { async create(userData: Partial<User>): Promise<User> { return User.create(userData); } } ```
2. Dependency Injection
Use dependency injection for better testability and maintainability:
```typescript import { Container } from 'inversify'; import { UserController } from './controllers/UserController'; import { UserService } from './services/UserService'; import { UserRepository } from './repositories/UserRepository';
const container = new Container(); container.bind<UserRepository>('UserRepository').to(UserRepository); container.bind<UserService>('UserService').to(UserService); container.bind<UserController>('UserController').to(UserController);
export { container }; ```
Performance Optimization
1. Database Optimization
Connection Pooling: ```typescript import { Pool } from 'pg';
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_`USER,`
password: process.env.DB_PASSWORD,
max: 20, // Maximum number of connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
```Query Optimization: ```typescript // Bad: N+1 Query Problem const users = await User.findAll(); for (const user of users) { user.posts = await Post.findAll({ where: { userId: user.id } }); }
// Good: Use includes/joins const users = await User.findAll({ include: [{ model: Post, as: 'posts' }] }); ```
2. Caching Strategies
Redis Implementation: ```typescript import Redis from 'ioredis';
const redis = new Redis({ host: process.env.REDIS_HOST, port: parseInt(process.env.REDIS_PORT || '6379'), retryDelayOnFailover: 100, maxRetriesPerRequest: 3, });
export class CacheService { async get<T>(key: string): Promise<T | null> { const cached = await redis.get(key); return cached ? JSON.parse(cached) : null; } async set(key: string, value: any, ttl: number = 3600): Promise<void> { await redis.setex(key, ttl, JSON.stringify(value)); } async invalidate(pattern: string): Promise<void> { const keys = await redis.keys(pattern); if (keys.length > 0) { await redis.del(...keys); } } } ```
Error Handling and Monitoring
Global Error Handler
```typescript import { Request, Response, NextFunction } from 'express';
export class AppError extends Error { statusCode: number; isOperational: boolean;
constructor(message: string, statusCode: number) { super(message); this.statusCode = statusCode; this.isOperational = true; Error.captureStackTrace(this, this.constructor); } }
export const globalErrorHandler = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
const { statusCode = 500, message } = err;
// Log error for monitoring
console.error(`Error ${statusCode}: ${message}`, {
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
});
res.status(statusCode).json({
status: 'error',
message: process.env.NODE_`ENV === 'production' `
? 'Something went wrong!'
: message,
});
};
```Security Best Practices
1. Input Validation
```typescript import Joi from 'joi';
const createUserSchema = Joi.object({ email: Joi.string().email().required(), password: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*d)/).required(), name: Joi.string().min(2).max(50).required(), });
export const validateCreateUser = (req: Request, res: Response, next: NextFunction) => { const { error } = createUserSchema.validate(req.body); if (error) { return res.status(400).json({ error: error.details[0].message }); } next(); }; ```
2. Rate Limiting
```typescript import rateLimit from 'express-rate-limit';
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', standardHeaders: true, legacyHeaders: false, });
app.use('/api/', apiLimiter); ```
Testing Strategy
Unit Tests
```typescript import { UserService } from '../services/UserService'; import { UserRepository } from '../repositories/UserRepository';
describe('UserService', () => { let userService: UserService; let mockUserRepository: jest.Mocked<UserRepository>;
beforeEach(() => { mockUserRepository = { create: jest.fn(), findById: jest.fn(), findByEmail: jest.fn(), } as any; userService = new UserService(mockUserRepository); });
describe('createUser', () => { it('should create user with hashed password', async () => { const userData = { email: 'test@example.com', password: 'password123', name: 'Test User', };
mockUserRepository.create.mockResolvedValue({ id: 1, ...userData, password: 'hashedPassword', } as any);
const result = await userService.createUser(userData);
expect(mockUserRepository.create).toHaveBeenCalledWith({ ...userData, password: expect.not.stringMatching('password123'), }); expect(result.password).not.toBe('password123'); }); }); }); ```
Deployment and Scaling
Docker Configuration
```dockerfile
`FROM node:18-alpine``WORKDIR /app``COPY package*.json ./`
`RUN npm ci --only=production``COPY . .`
`RUN npm run build``EXPOSE 3000``USER node``CMD ["npm", "start"]`
```Load Balancing with PM2
// ecosystem.config.js
module.exports = {
apps: [{
name: 'api',
script: 'dist/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_`ENV: 'production',`
PORT: 3000,
},
error_file: './logs/err.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
}],
};
Monitoring and Observability
Health Checks
app.get('/health', async (req, res) => {
const health = {
uptime: process.uptime(),
message: 'OK',
timestamp: Date.now(),
checks: {
database: await checkDatabase(),
redis: await checkRedis(),
memory: process.memoryUsage(),
},
};
const isHealthy = Object.values(health.checks).every(check =>
typeof check === 'object' ? check.status === 'ok' : true
);
res.status(isHealthy ? 200 : 503).json(health);
});
Conclusion
Building scalable APIs requires careful planning and implementation of best practices. Key takeaways:
- **Architecture**: Use layered architecture with clear separation of concerns
- **Performance**: Implement caching, optimize database queries, and use connection pooling
- **Security**: Validate inputs, implement rate limiting, and follow security best practices
- **Testing**: Write comprehensive tests for reliability
- **Monitoring**: Implement proper logging and health checks
By following these practices, you'll build APIs that can handle growth while maintaining code quality and developer productivity.
---*Have questions about API scalability? Feel free to reach out or leave a comment below!*