Node.js Backend Architecture: Patterns for Production
Backend

Node.js Backend Architecture: Patterns for Production

M

Md Nayeem Hossain

Author

Dec 22, 2024
14 min read

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

  • Controller Layer: Handles HTTP requests, parses inputs, and sends responses. It should *not* contain business logic.
  • Service Layer: Contains the business logic. This is where the "thinking" happens. It doesn't know about HTTP (req/res).
  • Repository/Data Access Layer: Handles communication with the database. It abstracts the SQL or MongoDB queries.
  • 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.

    typescript
    // 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."

    typescript
    // 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.

    typescript
    // 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.

    Node.js
    Express
    Architecture
    API

    © 2026 Md Nayeem Hossain. All rights reserved.