// Backend
Node.js Backend Architecture: Patterns for Production
Discover battle-tested architecture patterns for building robust and scalable Node.js backend applications.
Md Nayeem HossainDec 22, 202414 min read// Backend
Discover battle-tested architecture patterns for building robust and scalable Node.js backend applications.
Md Nayeem HossainDec 22, 202414 min read
When building a backend with Node.js, the freedom it offers can also be a curse. Without a strict framework, code can easily become "spaghetti." To build a system that can scale and be maintained by a team, you need a solid architecture.
I prefer a Layered Architecture, also known as n-tier architecture. It separates concerns into distinct layers, making the codebase modular and testable.
Here's how this looks in practice.
The controller is the entry point. It receives the request, calls the service, and returns the result.
1// controllers/user.controller.ts2import { Request, Response, NextFunction } from 'express';3import { UserService } from '../services/user.service';4 5export class UserController {6 constructor(private userService: UserService) {}7 8 async createUser(req: Request, res: Response, next: NextFunction) {9 try {10 // Validate input (very basic example)11 if (!req.body.email) throw new Error("Email required");12 13 // Call service14 const user = await this.userService.register(req.body);15 16 // Send response17 res.status(201).json({ success: true, data: user });18 } catch (error) {19 next(error); // Pass to error handling middleware20 }21 }22}The service layer contains your business rules. For example, "a user cannot register if the email already exists" or "password must be hashed."
1// services/user.service.ts2export class UserService {3 constructor(private userRepo: UserRepository) {}4 5 async register(userData: CreateUserDto) {6 // Check if user exists (Business Logic)7 const existing = await this.userRepo.findByEmail(userData.email);8 if (existing) {9 throw new AppError("User already exists", 400);10 }11 12 // Hash password (Business Logic)13 const hashedPassword = await bcrypt.hash(userData.password, 10);14 15 // Save to DB16 return this.userRepo.create({17 ...userData,18 password: hashedPassword19 });20 }21}The repository handles the raw database commands. If you change your ORM or database later, you only need to update this layer.
1// repositories/user.repository.ts2export class UserRepository {3 async findByEmail(email: string) {4 return prisma.user.findUnique({ where: { email } });5 }6 7 async create(data: any) {8 return prisma.user.create({ data });9 }10}Notice how we pass the dependencies (like userService) into the classes. This is Dependency Injection. It makes your code extremely easy to test because you can pass "mock" versions of these dependencies during testing without needing a real database.
This architecture has served me well in applications serving millions of users.