Acentrium Global Projects
Back to Blog
Tutorials

Building Scalable APIs with Node.js and TypeScript

A comprehensive guide to creating robust, scalable APIs using Node.js, TypeScript, and modern best practices.

Nneli JohnUI/UX Specialist
January 5, 2024
15 min read
📝

Building scalable APIs is crucial for modern applications. This guide covers best practices for creating robust, maintainable APIs using Node.js and TypeScript.

Project Setup

Initialize Your Project

Start with a solid foundation:

mkdir scalable-api
cd scalable-api
npm init -y
npm install express typescript @types/express
npm install -D nodemon ts-node

TypeScript Configuration

Create a robust tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Architecture Patterns

Layered Architecture

Organize your code into clear layers:

src/
├── controllers/
├── services/
├── models/
├── middleware/
├── routes/
└── utils/

Dependency Injection

Use dependency injection for better testability:

export class UserService {
  constructor(private userRepository: UserRepository) {}

  async createUser(userData: CreateUserDTO): Promise<User> {
    return this.userRepository.create(userData);
  }
}

Error Handling

Global Error Handler

Implement centralized error handling:

export class AppError extends Error {
  public statusCode: number;
  public isOperational: boolean;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
  }
}

export const globalErrorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: 'error',
      message: err.message
    });
  }

  console.error(err);
  res.status(500).json({
    status: 'error',
    message: 'Something went wrong'
  });
};

Validation and DTOs

Input Validation

Use Joi or Zod for request validation:

import Joi from 'joi';

export const createUserSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().min(8).required(),
  name: Joi.string().min(2).required()
});

export const validateCreateUser = (req: Request, res: Response, next: NextFunction) => {
  const { error } = createUserSchema.validate(req.body);
  if (error) {
    throw new AppError(error.details[0].message, 400);
  }
  next();
};

Database Integration

Repository Pattern

Abstract database operations:

export interface UserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  create(userData: CreateUserDTO): Promise<User>;
  update(id: string, userData: UpdateUserDTO): Promise<User>;
  delete(id: string): Promise<void>;
}

export class PrismaUserRepository implements UserRepository {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<User | null> {
    return this.prisma.user.findUnique({ where: { id } });
  }

  // ... other methods
}

Authentication & Authorization

JWT Implementation

Secure your API with JWT:

export const authenticateToken = (req: Request, res: Response, next: NextFunction) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    throw new AppError('Access token required', 401);
  }

  jwt.verify(token, process.env.JWT_SECRET!, (err, user) => {
    if (err) {
      throw new AppError('Invalid token', 403);
    }
    req.user = user;
    next();
  });
};

Caching Strategies

Redis Integration

Implement caching for better performance:

export class CacheService {
  constructor(private redis: Redis) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    return value ? JSON.parse(value) : null;
  }

  async set(key: string, value: any, ttl: number = 3600): Promise<void> {
    await this.redis.setex(key, ttl, JSON.stringify(value));
  }

  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }
}

Rate Limiting

Protect Your API

Implement rate limiting to prevent abuse:

import rateLimit from 'express-rate-limit';

export 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'
});

export const strictLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5, // stricter limit for sensitive endpoints
  message: 'Too many attempts, please try again later'
});

Testing

Unit Testing

Write comprehensive tests:

describe('UserService', () => {
  let userService: UserService;
  let mockUserRepository: jest.Mocked<UserRepository>;

  beforeEach(() => {
    mockUserRepository = {
      findById: jest.fn(),
      create: jest.fn(),
      // ... other methods
    } as jest.Mocked<UserRepository>;

    userService = new UserService(mockUserRepository);
  });

  it('should create a user successfully', async () => {
    const userData = { email: 'test@example.com', name: 'Test User' };
    const expectedUser = { id: '1', ...userData };

    mockUserRepository.create.mockResolvedValue(expectedUser);

    const result = await userService.createUser(userData);

    expect(result).toEqual(expectedUser);
    expect(mockUserRepository.create).toHaveBeenCalledWith(userData);
  });
});

Performance Optimization

Database Optimization

Optimize your database queries:

  • Use connection pooling
  • Implement query optimization
  • Add appropriate indexes
  • Use database transactions when needed

Response Compression

Enable gzip compression:

import compression from 'compression';
app.use(compression());

Monitoring and Logging

Structured Logging

Implement proper logging:

import winston from 'winston';

export const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});

Deployment Considerations

Environment Configuration

Use environment-specific configurations:

export const config = {
  port: process.env.PORT || 3000,
  dbUrl: process.env.DATABASE_URL!,
  jwtSecret: process.env.JWT_SECRET!,
  redisUrl: process.env.REDIS_URL,
  environment: process.env.NODE_ENV || 'development'
};

Conclusion

Building scalable APIs requires careful attention to architecture, error handling, security, and performance. By following these practices and using TypeScript for better type safety, you can create robust APIs that scale with your application's needs.

Remember to continuously monitor your API's performance, implement proper testing, and keep security considerations at the forefront of your development process.

Tags

Node.jsTypeScriptAPI DevelopmentBackend
N

Nneli John

UI/UX Specialist

Nneli John is a seasoned professional at Acentrium Global Projects, bringing expertise in tutorials to help businesses achieve their digital transformation goals.

Article Info

Reading Time
15 min read
Published
January 5, 2024
Category
tutorials
Tags
4 tags

Share Article

Need Expert Help?

Get professional guidance from our team of experts.

Enjoyed This Article?

Stay updated with our latest insights and expert tips delivered to your inbox.