DEV Community

Cover image for Production-Grade Express.js Architecture: Layered Controller, Service, and Repository Pattern
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Production-Grade Express.js Architecture: Layered Controller, Service, and Repository Pattern

Production-Grade Express.js Architecture: Layered Controller, Service, and Repository Pattern

title: "Production-Grade Express.js Architecture: Layered Controller, Service, and Repository Pattern"
published: true
published_at: "2026-11-10T09:00:00+05:30"
description: "Explore how to structure a scalable, maintainable Express.js application using the Layered Architecture pattern, separating routes, controllers, services, and repositories with dependency injection and centralized error handling."
tags: [express, nodejs, backend, architecture]
ai_disclosure_level: some_ai

Production-Grade Express.js Architecture: Layered Controller, Service, and Repository Pattern

When building backend applications with Node.js and Express.js, developers often start with a monolithic file structure. A single app.js handles routing, database connections, input validation, and business logic. While this approach accelerates initial prototyping, it quickly collapses under the weight of growing requirements, making unit testing difficult and violating the Single Responsibility Principle (SRP).

To build scalable, maintainable, and enterprise-ready APIs, we need a robust architectural pattern. In this article, we will implement a production-grade Layered Architecture utilizing Controllers, Services, and Repositories, coupled with Dependency Injection and Centralized Error Handling.

The Layered Architecture Overview

Separation of concerns is the guiding principle of this architecture. Each layer has a specific, well-defined job and communicates only with adjacent layers.

+-------------------------------------------------------------+
|                        HTTP Layer                           | 
|                (Routes & Express Framework)                 |
+------------------------------+------------------------------+
                               |
                               v
+------------------------------+------------------------------+
|                       Controller Layer                      | 
|       (Request parsing, Response formatting, Status codes)  |
+------------------------------+------------------------------+
                               |
                               v
+------------------------------+------------------------------+
|                        Service Layer                        | 
|              (Business logic, Transactions, Rules)          |
+------------------------------+------------------------------+
                               |
                               v
+------------------------------+------------------------------+
|                       Repository Layer                      | 
|       (Data access, ORM queries, Database interaction)      |
+-------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode
  1. HTTP/Router Layer: Maps URL paths and HTTP methods to specific controller methods. It acts as the entry point for Express.
  2. Controller Layer: Extracts data from HTTP requests (params, query, body), invokes the corresponding service, and formats the HTTP response or delegates errors.
  3. Service Layer: Houses the core business logic. It remains completely agnostic of HTTP details (like req and res) and database drivers.
  4. Repository Layer: Encapsulates data persistence logic. It interacts directly with the database (using ORMs like Prisma, Sequelize, or native drivers like pg).

---n

Step 1: Centralized Error Handling

Before writing our layers, we need a predictable error-handling strategy. Instead of scattering try/catch blocks and manual status codes across controllers, we create custom error classes and a centralized Express error-handling middleware.

// errors/app-error.js
class AppError extends Error {
  constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
    this.isOperational = true;

    Error.captureStackTrace(this, this.constructor);
  }
}

module.exports = AppError;
Enter fullscreen mode Exit fullscreen mode

Now, let's create the global error-handling middleware:

// middlewares/error.middleware.js
const errorHandler = (err, req, res, next) => {
  err.statusCode = err.statusCode || 500;
  err.status = err.status || 'error';

  if (process.env.NODE_ENV === 'development') {
    res.status(err.statusCode).json({
      status: err.status,
      error: err,
      message: err.message,
      stack: err.stack,
    });
  } else {
    // Production: Don't leak leak implementation details
    if (err.isOperational) {
      res.status(err.statusCode).json({
        status: err.status,
        message: err.message,
      });
    } else {
      console.error('ERROR 💥', err);
      res.status(500).json({
        status: 'error',
        message: 'Something went very wrong!',
      });
    }
  }
};

module.exports = errorHandler;
Enter fullscreen mode Exit fullscreen mode

---n

Step 2: The Repository Layer

The repository layer isolates data access logic. By abstracting database operations behind a repository interface, we can easily swap database technologies or mock data during unit testing.

// repositories/user.repository.js
class UserRepository {
  constructor(dbClient) {
    this.db = dbClient; // e.g., Prisma client or Mongoose model
  }

  async findById(id) {
    return await this.db.user.findUnique({ where: { id } });
  }

  async findByEmail(email) {
    return await this.db.user.findUnique({ where: { email } });
  }

  async create(userData) {
    return await this.db.user.create({ data: userData });
  }
}

module.exports = UserRepository;
Enter fullscreen mode Exit fullscreen mode

---n

Step 3: The Service Layer

The service layer contains the application's business rules. Notice that it knows nothing about HTTP protocols, status codes (200, 400), or req/res objects. It throws standard AppError instances when business constraints are violated.

// services/user.service.js
const AppError = require('../errors/app-error');
const bcrypt = require('bcrypt');

class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async registerUser(userData) {
    const existingUser = await this.userRepository.findByEmail(userData.email);
    if (existingUser) {
      throw new AppError('Email is already in use.', 400);
    }

    const hashedPassword = await bcrypt.hash(userData.password, 12);

    const newUser = await this.userRepository.create({
      ...userData,
      password: hashedPassword,
    });

    // Remove password before returning user object
    delete newUser.password;
    return newUser;
  }

  async getUserProfile(userId) {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new AppError('User not found.', 404);
    }

    delete user.password;
    return user;
  }
}

module.exports = UserService;
Enter fullscreen mode Exit fullscreen mode

---n

Step 4: The Controller Layer

Controllers act as the bridge between the transport layer (Express) and the application core (Services). They parse the incoming HTTP request, call the service method, and format the response.

To avoid repetitive try/catch blocks in every controller, we can use an asynchronous wrapper utility.

// utils/catch-async.js
const catchAsync = (fn) => {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
};

module.exports = catchAsync;
Enter fullscreen mode Exit fullscreen mode

Now, implement the UserController:

// controllers/user.controller.js
const catchAsync = require('../utils/catch-async');

class UserController {
  constructor(userService) {
    this.userService = userService;
  }

  register = catchAsync(async (req, res, next) => {
    const newUser = await this.userService.registerUser(req.body);

    res.status(201).json({
      status: 'success',
      data: {
        user: newUser,
      },
    });
  });

  getProfile = catchAsync(async (req, res, next) => {
    const user = await this.userService.getUserProfile(req.params.id);

    res.status(200).json({
      status: 'success',
      data: {
        user,
      },
    });
  });
}

module.exports = UserController;
Enter fullscreen mode Exit fullscreen mode

---n

Step 5: Dependency Injection & Wiring

Dependency Injection (DI) allows us to pass dependencies (like repositories to services, and services to controllers) rather than instantiating them directly inside the files. This is essential for writing clean unit tests.

// app.js (Wiring)
const express = require('express');
const { PrismaClient } = require('@prisma/client');

const UserRepository = require('./repositories/user.repository');
const UserService = require('./services/user.service');
const UserController = require('./controllers/user.controller');

const userRouter = require('./routes/user.routes');
const errorHandler = require('./middlewares/error.middleware');

const app = express();
app.use(express.json());

// 1. Initialize Infrastructure
const prisma = new PrismaClient();

// 2. Instantiate Layers
const userRepository = new UserRepository(prisma);
const userService = new UserService(userRepository);
const userController = new UserController(userService);

// 3. Mount Routes (Injecting controller into router)
app.use('/api/v1/users', userRouter(userController));

// 4. Centralized Error Handling Middleware
app.use(errorHandler);

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

And the routing file:

// routes/user.routes.js
const { Router } = require('express');

module.exports = (userController) => {
  const router = Router();

  router.post('/register', userController.register);
  router.get('/:id', userController.getProfile);

  return router;
};
Enter fullscreen mode Exit fullscreen mode

---n

Summary

By adopting a Layered Architecture in Express.js, you gain:

  • Testability: You can easily unit test UserService by passing a mocked UserRepository without starting an HTTP server or connecting to a live database.
  • Maintainability: Changes to the database schema or ORM only affect the Repository layer. Changes to business rules only affect the Service layer.
  • Readability: Code is organized logically, helping new developers onboard to the codebase rapidly.

Top comments (0)