Node.js Microservices with Express and TypeScript
Introduction
Node.js is an excellent choice for building microservices due to its non-blocking I/O model and large ecosystem. Combined with TypeScript and Express, you can build robust, type-safe microservices.
Project Structure
src/
├── api/
│ ├── middleware/
│ ├── routes/
│ └── validation/
├── config/
├── services/
├── models/
├── utils/
├── types/
└── app.ts
Express with TypeScript Setup
typescript1// app.ts 2import express, { Application, Request, Response, NextFunction } from 'express'; 3import helmet from 'helmet'; 4import cors from 'cors'; 5import compression from 'compression'; 6import rateLimit from 'express-rate-limit'; 7import { errorHandler } from './api/middleware/errorHandler'; 8import { logger } from './utils/logger'; 9 10const app: Application = express(); 11 12// Security middleware 13app.use(helmet()); 14app.use(cors({ 15 origin: process.env.CORS_ORIGIN || '*', 16 credentials: true 17})); 18app.use(compression()); 19 20// Rate limiting 21const limiter = rateLimit({ 22 windowMs: 15 * 60 * 1000, // 15 minutes 23 max: 100, // limit each IP to 100 requests per windowMs 24 message: 'Too many requests from this IP, please try again later.' 25}); 26app.use('/api/', limiter); 27 28// Body parsing 29app.use(express.json({ limit: '10mb' })); 30app.use(express.urlencoded({ extended: true })); 31 32// Request logging 33app.use((req: Request, res: Response, next: NextFunction) => { 34 logger.info(`${req.method} ${req.path}`); 35 next(); 36}); 37 38// Routes 39import userRoutes from './api/routes/userRoutes'; 40import orderRoutes from './api/routes/orderRoutes'; 41app.use('/api/users', userRoutes); 42app.use('/api/orders', orderRoutes); 43 44// Health check 45app.get('/health', (req: Request, res: Response) => { 46 res.json({ status: 'healthy', timestamp: new Date().toISOString() }); 47}); 48 49// 404 handler 50app.use((req: Request, res: Response) => { 51 res.status(404).json({ error: 'Route not found' }); 52}); 53 54// Error handler 55app.use(errorHandler); 56 57const PORT = process.env.PORT || 3000; 58app.listen(PORT, () => { 59 logger.info(`Server running on port ${PORT}`); 60});
Type-Safe Route Handler
typescript1// api/routes/userRoutes.ts 2import { Router, Request, Response } from 'express'; 3import { body, validationResult } from 'express-validator'; 4import { UserService } from '../../services/UserService'; 5import { asyncHandler } from '../middleware/asyncHandler'; 6 7type CreateUserBody = { 8 email: string; 9 name: string; 10 password: string; 11}; 12 13type UpdateUserBody = Partial<CreateUserBody>; 14 15type UserParams = { 16 id: string; 17}; 18 19const router = Router(); 20const userService = new UserService(); 21 22router.post('/', 23 [ 24 body('email').isEmail().normalizeEmail(), 25 body('name').trim().isLength({ min: 2, max: 50 }), 26 body('password').isLength({ min: 8 }) 27 ], 28 asyncHandler(async (req: Request<{}, {}, CreateUserBody>, res: Response) => { 29 const errors = validationResult(req); 30 if (!errors.isEmpty()) { 31 return res.status(400).json({ errors: errors.array() }); 32 } 33 34 const user = await userService.createUser(req.body); 35 res.status(201).json(user); 36 }) 37); 38 39router.get('/:id', 40 asyncHandler(async (req: Request<UserParams>, res: Response) => { 41 const user = await userService.getUserById(req.params.id); 42 43 if (!user) { 44 return res.status(404).json({ error: 'User not found' }); 45 } 46 47 res.json(user); 48 }) 49); 50 51router.put('/:id', 52 [ 53 body('email').optional().isEmail().normalizeEmail(), 54 body('name').optional().trim().isLength({ min: 2, max: 50 }), 55 body('password').optional().isLength({ min: 8 }) 56 ], 57 asyncHandler(async (req: Request<UserParams, {}, UpdateUserBody>, res: Response) => { 58 const errors = validationResult(req); 59 if (!errors.isEmpty()) { 60 return res.status(400).json({ errors: errors.array() }); 61 } 62 63 const updatedUser = await userService.updateUser(req.params.id, req.body); 64 res.json(updatedUser); 65 }) 66); 67 68export default router;
Service Layer with Dependency Injection
typescript1// services/UserService.ts 2import { inject, injectable } from 'tsyringe'; 3import { IUserRepository } from '../repositories/IUserRepository'; 4import { ILogger } from '../utils/ILogger'; 5import { User } from '../models/User'; 6 7type CreateUserDTO = { 8 email: string; 9 name: string; 10 password: string; 11}; 12 13@injectable() 14export class UserService { 15 constructor( 16 @inject('IUserRepository') private userRepository: IUserRepository, 17 @inject('ILogger') private logger: ILogger 18 ) {} 19 20 async createUser(data: CreateUserDTO): Promise<User> { 21 this.logger.info('Creating user', { email: data.email }); 22 23 // Check if user exists 24 const existingUser = await this.userRepository.findByEmail(data.email); 25 if (existingUser) { 26 throw new Error('User with this email already exists'); 27 } 28 29 // Hash password 30 const hashedPassword = await this.hashPassword(data.password); 31 32 // Create user 33 const user = await this.userRepository.create({ 34 ...data, 35 password: hashedPassword 36 }); 37 38 this.logger.info('User created successfully', { userId: user.id }); 39 return user; 40 } 41 42 async getUserById(id: string): Promise<User | null> { 43 return this.userRepository.findById(id); 44 } 45 46 async updateUser(id: string, data: Partial<CreateUserDTO>): Promise<User> { 47 const user = await this.userRepository.findById(id); 48 if (!user) { 49 throw new Error('User not found'); 50 } 51 52 if (data.password) { 53 data.password = await this.hashPassword(data.password); 54 } 55 56 const updatedUser = await this.userRepository.update(id, data); 57 return updatedUser; 58 } 59 60 private async hashPassword(password: string): Promise<string> { 61 const bcrypt = await import('bcrypt'); 62 const saltRounds = 10; 63 return bcrypt.hash(password, saltRounds); 64 } 65}
Repository Pattern with TypeORM
typescript1// repositories/UserRepository.ts 2import { Repository } from 'typeorm'; 3import { AppDataSource } from '../../config/database'; 4import { User } from '../models/User'; 5import { IUserRepository } from './IUserRepository'; 6 7export class UserRepository implements IUserRepository { 8 private repository: Repository<User>; 9 10 constructor() { 11 this.repository = AppDataSource.getRepository(User); 12 } 13 14 async findById(id: string): Promise<User | null> { 15 return this.repository.findOne({ where: { id } }); 16 } 17 18 async findByEmail(email: string): Promise<User | null> { 19 return this.repository.findOne({ where: { email } }); 20 } 21 22 async create(data: Partial<User>): Promise<User> { 23 const user = this.repository.create(data); 24 return this.repository.save(user); 25 } 26 27 async update(id: string, data: Partial<User>): Promise<User> { 28 await this.repository.update(id, data); 29 const user = await this.findById(id); 30 if (!user) { 31 throw new Error('User not found after update'); 32 } 33 return user; 34 } 35 36 async delete(id: string): Promise<boolean> { 37 const result = await this.repository.delete(id); 38 return result.affected !== null && result.affected > 0; 39 } 40}
Error Handling Middleware
typescript1// api/middleware/errorHandler.ts 2import { Request, Response, NextFunction } from 'express'; 3import { logger } from '../../utils/logger'; 4 5export class AppError extends Error { 6 constructor( 7 public statusCode: number, 8 public message: string, 9 public isOperational = true 10 ) { 11 super(message); 12 Object.setPrototypeOf(this, AppError.prototype); 13 } 14} 15 16export const errorHandler = ( 17 err: Error, 18 req: Request, 19 res: Response, 20 next: NextFunction 21) => { 22 logger.error('Error occurred:', { 23 error: err.message, 24 stack: err.stack, 25 path: req.path, 26 method: req.method 27 }); 28 29 if (err instanceof AppError) { 30 return res.status(err.statusCode).json({ 31 error: err.message, 32 ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) 33 }); 34 } 35 36 // Default error 37 res.status(500).json({ 38 error: 'Internal server error', 39 ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) 40 }); 41};
Testing with Jest
typescript1// tests/services/UserService.test.ts 2import { UserService } from '../../services/UserService'; 3import { mockUserRepository } from '../mocks/UserRepository'; 4import { mockLogger } from '../mocks/Logger'; 5 6describe('UserService', () => { 7 let userService: UserService; 8 9 beforeEach(() => { 10 userService = new UserService(mockUserRepository, mockLogger); 11 }); 12 13 describe('createUser', () => { 14 it('should create a user successfully', async () => { 15 const userData = { 16 email: 'test@example.com', 17 name: 'Test User', 18 password: 'password123' 19 }; 20 21 mockUserRepository.findByEmail.mockResolvedValue(null); 22 mockUserRepository.create.mockResolvedValue({ 23 id: '1', 24 ...userData, 25 createdAt: new Date() 26 }); 27 28 const result = await userService.createUser(userData); 29 30 expect(result).toHaveProperty('id'); 31 expect(result.email).toBe(userData.email); 32 expect(mockUserRepository.create).toHaveBeenCalledWith( 33 expect.objectContaining({ 34 email: userData.email, 35 name: userData.name 36 }) 37 ); 38 }); 39 40 it('should throw error if user already exists', async () => { 41 const userData = { 42 email: 'existing@example.com', 43 name: 'Existing User', 44 password: 'password123' 45 }; 46 47 mockUserRepository.findByEmail.mockResolvedValue({ 48 id: '1', 49 ...userData 50 }); 51 52 await expect(userService.createUser(userData)) 53 .rejects 54 .toThrow('User with this email already exists'); 55 }); 56 }); 57});
Docker Configuration
dockerfile1# Dockerfile 2FROM node:18-alpine 3 4WORKDIR /app 5 6# Install dependencies 7COPY package*.json ./ 8RUN npm ci --only=production 9 10# Copy source 11COPY . . 12 13# Build TypeScript 14RUN npm run build 15 16# Remove dev dependencies 17RUN npm prune --production 18 19EXPOSE 3000 20 21CMD ["node", "dist/app.js"]
yaml1# docker-compose.yml 2version: '3.8' 3services: 4 api: 5 build: . 6 ports: 7 - "3000:3000" 8 environment: 9 - NODE_ENV=production 10 - DATABASE_URL=postgresql://db:5432/mydb 11 - REDIS_URL=redis://redis:6379 12 depends_on: 13 - db 14 - redis 15 restart: unless-stopped 16 17 db: 18 image: postgres:15-alpine 19 environment: 20 - POSTGRES_DB=mydb 21 - POSTGRES_USER=user 22 - POSTGRES_PASSWORD=password 23 volumes: 24 - postgres_data:/var/lib/postgresql/data 25 26 redis: 27 image: redis:7-alpine 28 command: redis-server --appendonly yes 29 volumes: 30 - redis_data:/data 31 32volumes: 33 postgres_data: 34 redis_data: