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) |
+-------------------------------------------------------------+
- HTTP/Router Layer: Maps URL paths and HTTP methods to specific controller methods. It acts as the entry point for Express.
- Controller Layer: Extracts data from HTTP requests (params, query, body), invokes the corresponding service, and formats the HTTP response or delegates errors.
-
Service Layer: Houses the core business logic. It remains completely agnostic of HTTP details (like
reqandres) and database drivers. -
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;
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;
---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;
---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;
---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;
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;
---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;
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;
};
---n
Summary
By adopting a Layered Architecture in Express.js, you gain:
-
Testability: You can easily unit test
UserServiceby passing a mockedUserRepositorywithout 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)