
Node.js Backend Architecture: Patterns for Production
Md Nayeem Hossain
Author
Md Nayeem Hossain
Author
Node.js Backend Architecture Patterns
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.
The Layers
Here's how this looks in practice.
1. Controller Layer
The controller is the entry point. It receives the request, calls the service, and returns the result.
// controllers/user.controller.ts
import { Request, Response, NextFunction } from 'express';
import { UserService } from '../services/user.service';
export class UserController {
constructor(private userService: UserService) {}
async createUser(req: Request, res: Response, next: NextFunction) {
try {
// Validate input (very basic example)
if (!req.body.email) throw new Error("Email required");
// Call service
const user = await this.userService.register(req.body);
// Send response
res.status(201).json({ success: true, data: user });
} catch (error) {
next(error); // Pass to error handling middleware
}
}
}2. Service Layer
The service layer contains your business rules. For example, "a user cannot register if the email already exists" or "password must be hashed."
// services/user.service.ts
export class UserService {
constructor(private userRepo: UserRepository) {}
async register(userData: CreateUserDto) {
// Check if user exists (Business Logic)
const existing = await this.userRepo.findByEmail(userData.email);
if (existing) {
throw new AppError("User already exists", 400);
}
// Hash password (Business Logic)
const hashedPassword = await bcrypt.hash(userData.password, 10);
// Save to DB
return this.userRepo.create({
...userData,
password: hashedPassword
});
}
}3. Repository Layer
The repository handles the raw database commands. If you change your ORM or database later, you only need to update this layer.
// repositories/user.repository.ts
export class UserRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email } });
}
async create(data: any) {
return prisma.user.create({ data });
}
}Dependency Injection
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.


