Most Node.js projects start clean. Then six months pass, your team adds features under pressure, and suddenly your routes/index.js file is 1,200 lines long. Nobody wants to touch it. Reviews take forever. Bugs hide in plain sight.
I have been there. And I want to show you exactly how to get out of it.
This guide walks through a practical, battle-tested architecture pattern for Express.js APIs that scales without turning into a maze. No over-engineering. No 15 abstraction layers. Just clean, readable, maintainable code.
The Problem with "Just Slap It in Routes"
Here is what a typical Express.js project looks like after a few months of fast iteration:
// routes/users.js - the horror
router.post('/register', async (req, res) => {
const { email, password, name } = req.body;
// validate inline
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
// hash password inline
const hashed = await bcrypt.hash(password, 10);
// query database inline
const existing = await db.query('SELECT * FROM users WHERE email = ?', [email]);
if (existing.length > 0) {
return res.status(409).json({ error: 'User exists' });
}
const user = await db.query(
'INSERT INTO users (email, password, name) VALUES (?, ?, ?)',
[email, hashed, name]
);
// send email inline
await transporter.sendMail({
to: email,
subject: 'Welcome!',
text: `Hi ${name}, welcome aboard.`
});
const token = jwt.sign({ id: user.insertId }, process.env.JWT_SECRET);
res.json({ token });
});
This works. Until:
- You need to reuse the registration logic in an admin endpoint
- You want to unit test just the validation
- A bug in email sending breaks registration entirely
- A new dev needs to understand what this endpoint does
The Architecture That Actually Works
Here is the structure I have settled on after years of building Node.js backends:
src/
routes/ # HTTP layer only — routing + request/response
controllers/ # Coordinate the flow, call services
services/ # Business logic, reusable across controllers
repositories/ # Database access only
middleware/ # Auth, validation, error handling
validators/ # Input schemas (Joi, Zod, etc.)
utils/ # Pure helper functions
Each layer has one job. Let me show you how this looks in practice.
Layer 1: Routes — Just Map URLs to Controllers
// routes/users.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
const { validateBody } = require('../middleware/validate');
const { registerSchema } = require('../validators/userValidators');
const { authenticate } = require('../middleware/auth');
router.post('/register', validateBody(registerSchema), usersController.register);
router.get('/me', authenticate, usersController.getProfile);
router.patch('/me', authenticate, validateBody(updateSchema), usersController.updateProfile);
module.exports = router;
The route file is now a table of contents. You can scan it in seconds and understand every endpoint.
Layer 2: Controllers — Coordinate, Don't Compute
// controllers/usersController.js
const usersService = require('../services/usersService');
const { AppError } = require('../utils/errors');
const register = async (req, res, next) => {
try {
const { email, password, name } = req.body;
const result = await usersService.registerUser({ email, password, name });
res.status(201).json(result);
} catch (err) {
next(err);
}
};
const getProfile = async (req, res, next) => {
try {
const user = await usersService.getUserById(req.user.id);
res.json(user);
} catch (err) {
next(err);
}
};
module.exports = { register, getProfile };
Controllers are thin. They extract data from the request, pass it to services, and shape the response. No business logic lives here.
Layer 3: Services — Where the Real Logic Lives
// services/usersService.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const usersRepository = require('../repositories/usersRepository');
const emailService = require('./emailService');
const { AppError } = require('../utils/errors');
const registerUser = async ({ email, password, name }) => {
const existing = await usersRepository.findByEmail(email);
if (existing) {
throw new AppError('Email already registered', 409);
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await usersRepository.create({ email, password: hashedPassword, name });
// email failure should not break registration
emailService.sendWelcome(user).catch(err => {
console.error('Welcome email failed:', err.message);
});
const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
return { token, user: { id: user.id, email: user.email, name: user.name } };
};
const getUserById = async (id) => {
const user = await usersRepository.findById(id);
if (!user) throw new AppError('User not found', 404);
return user;
};
module.exports = { registerUser, getUserById };
Notice the email failure handling. Business decisions live here — like "a welcome email failure should not block registration."
Layer 4: Repositories — Speak Only SQL (or ORM)
// repositories/usersRepository.js
const db = require('../db');
const findByEmail = async (email) => {
const [rows] = await db.execute('SELECT * FROM users WHERE email = ?', [email]);
return rows[0] || null;
};
const findById = async (id) => {
const [rows] = await db.execute('SELECT id, email, name, created_at FROM users WHERE id = ?', [id]);
return rows[0] || null;
};
const create = async ({ email, password, name }) => {
const [result] = await db.execute(
'INSERT INTO users (email, password, name) VALUES (?, ?, ?)',
[email, password, name]
);
return findById(result.insertId);
};
module.exports = { findByEmail, findById, create };
Swapping MySQL for PostgreSQL? Only this file changes. Your service layer never knows the difference.
The Glue: Validation and Error Handling Middleware
// middleware/validate.js
const validateBody = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map(d => d.message);
return res.status(400).json({ error: 'Validation failed', details: messages });
}
req.body = value; // use sanitized values
next();
};
module.exports = { validateBody };
// middleware/errorHandler.js
const { AppError } = require('../utils/errors');
const errorHandler = (err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Something went wrong' });
};
module.exports = errorHandler;
// utils/errors.js
class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = { AppError };
Every thrown AppError bubbles up cleanly. No more scattered res.status(500).json(...) calls across your codebase.
Why This Architecture Wins
Testing becomes trivial. You can unit test a service by mocking the repository. No HTTP server needed, no database needed.
// tests/services/usersService.test.js
jest.mock('../../repositories/usersRepository');
const usersRepository = require('../../repositories/usersRepository');
const { registerUser } = require('../../services/usersService');
test('throws 409 if email already exists', async () => {
usersRepository.findByEmail.mockResolvedValue({ id: 1, email: 'test@test.com' });
await expect(registerUser({ email: 'test@test.com', password: 'pass', name: 'Test' }))
.rejects.toMatchObject({ statusCode: 409 });
});
Onboarding is fast. New developers look at routes to understand the API surface. They look at services to understand the business rules. They never have to read 400 lines to find one bug.
Refactoring is safe. When you add a new auth provider (say, Google OAuth), you call usersService.registerUser() from a new controller. The business logic is already there.
Common Mistakes to Avoid
Putting database calls in controllers. The moment your controller imports
db, you have broken the pattern.Fat repositories. Repositories should not contain business logic. If you are writing
ifstatements based on user roles inside a repository, stop.Services calling other services in circles. If
usersServicecallsordersServicewhich callsusersService, you have a design problem. Extract shared logic to a utility or rethink your domain boundaries.Ignoring async error handling. Wrap all async controller functions with a try/catch or use an async wrapper utility. Unhandled promise rejections will crash your server.
The Payoff
I have applied this pattern to projects ranging from small side projects to APIs serving millions of requests per day. The initial setup takes maybe an extra hour. The payoff is measured in days of saved debugging time over the life of the project.
Your routes file becomes a map. Your controllers become traffic directors. Your services become the single source of truth for how your application behaves. Your repositories become a clean data access layer you can swap or mock at will.
Clean architecture is not about being clever. It is about making the next developer (often future you) able to understand what is happening without a guide.
Start with one endpoint. Refactor it into this pattern. See how it feels. I think you will not go back.
Have you built an Express.js API that grew out of control? What patterns helped you tame it? Drop your experience in the comments.
Top comments (0)