Skip to content

How to Structure Express.js Applications for Production with Layered Architecture

Problem

When I started building Express.js applications, I followed the tutorials. They showed me how to create routes, handle requests, and return responses. Everything worked great for my hobby projects.

Then I tried to scale.

My routes became massive. Testing was a nightmare. Every database schema change broke multiple endpoints. I couldn’t figure out where business logic lived anymore—it was scattered everywhere.

Here’s what my “hobby” architecture looked like:

routes/users.js
router.post('/users', async (req, res) => {
const { email, password } = req.body;
// Validation mixed with business logic
if (!email || !password) {
return res.status(400).json({ error: 'Missing fields' });
}
// Database queries directly in route
const existingUser = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'User exists' });
}
// Hashing, tokens, emails - all mixed together
const hashedPassword = await bcrypt.hash(password, 10);
const result = await db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
const token = jwt.sign({ userId: result.rows[0].id }, process.env.JWT_SECRET);
await sendWelcomeEmail(email);
res.status(201).json({ user: result.rows[0], token });
});

This code works. But it has problems:

  • Untestable: How do I unit test the business logic without a database?
  • Coupled: Change the database schema? I need to rewrite my routes.
  • Duplicated: Need similar logic elsewhere? Copy-paste (and bugs).
  • Unclear: What does this endpoint actually do? Hard to tell at a glance.

Solution

The fix is a layered architecture where Express routes stay thin, business logic lives in a service layer, and data access is handled through a repository pattern.

architecture-layers.txt
+-----------------------------------------------------+
| Routes Layer |
| (HTTP handling, request parsing, response) |
+-----------------------------------------------------+
| Service Layer |
| (Business logic, validation, orchestration) |
+-----------------------------------------------------+
| Repository Layer |
| (Data access, queries, persistence) |
+-----------------------------------------------------+

Repository Layer

The repository handles all database operations. It returns domain models, not raw database rows.

repositories/userRepository.js
class UserRepository {
constructor(database) {
this.db = database;
}
async findById(id) {
const result = await this.db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return result.rows[0] ? this.toDomain(result.rows[0]) : null;
}
async findByEmail(email) {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] ? this.toDomain(result.rows[0]) : null;
}
async create(userData) {
const result = await this.db.query(
'INSERT INTO users (email, password, created_at) VALUES ($1, $2, NOW()) RETURNING *',
[userData.email, userData.hashedPassword]
);
return this.toDomain(result.rows[0]);
}
toDomain(row) {
return {
id: row.id,
email: row.email,
createdAt: row.created_at
};
}
}
module.exports = UserRepository;

Service Layer

The service layer contains all business logic. It orchestrates operations and doesn’t know about HTTP.

services/userService.js
class UserService {
constructor(userRepository, emailService, tokenService) {
this.userRepo = userRepository;
this.emailService = emailService;
this.tokenService = tokenService;
}
async registerUser(email, password) {
// Validation
if (!email || !password) {
throw new ValidationError('Email and password are required');
}
if (!this.isValidEmail(email)) {
throw new ValidationError('Invalid email format');
}
// Check existing user
const existing = await this.userRepo.findByEmail(email);
if (existing) {
throw new ConflictError('User already exists');
}
// Create user
const hashedPassword = await this.hashPassword(password);
const user = await this.userRepo.create({
email,
hashedPassword
});
// Generate token
const token = this.tokenService.generateForUser(user);
// Send welcome email (non-blocking)
this.emailService.sendWelcome(email).catch(err => {
console.error('Failed to send welcome email:', err);
});
return { user, token };
}
isValidEmail(email) {
return email.includes('@');
}
async hashPassword(password) {
const bcrypt = require('bcrypt');
return bcrypt.hash(password, 10);
}
}
module.exports = UserService;

Routes Layer

Routes become thin—they only handle HTTP concerns.

routes/users.js
const express = require('express');
const router = express.Router();
router.post('/users', async (req, res, next) => {
try {
const { email, password } = req.body;
const result = await req.userService.registerUser(email, password);
res.status(201).json(result);
} catch (error) {
next(error);
}
});
module.exports = router;

Notice what happened:

  • The route doesn’t know about databases, hashing, or emails
  • The service doesn’t know about HTTP requests or responses
  • The repository doesn’t know about business rules

Why This Matters

Here’s a comparison of how concerns are handled:

ConcernHobby ArchitectureLayered Architecture
TestingIntegration tests only, slowUnit tests for logic, fast
Database changeRewrite all routesUpdate repository only
Business rule changeFind all routes, fix bugsOne service method
New featureCopy-paste logicReuse service methods
DebuggingWhere is the bug?Check the layer responsible

Let me show you a real scenario. I needed to switch from PostgreSQL to MongoDB on one project.

With layered architecture: I wrote a MongoUserRepository implementing the same interface. Routes and services didn’t change at all.

With hobby architecture: I would have rewritten every single route.

Common Mistakes

I’ve made these mistakes. Learn from them.

Mistake 1: Anemic Services

The service layer becomes a passthrough with no real logic:

services/badUserService.js
// BAD: Service does nothing useful
async getUser(id) {
return this.userRepository.findById(id);
}

Services should contain business logic. If your service just calls the repository, you’re missing the point.

Mistake 2: Fat Routes

Business logic leaking into controllers:

routes/badUsers.js
// BAD: Route doing validation that belongs in service
router.post('/users', async (req, res) => {
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Invalid email' });
}
// ... more business logic in route
});

Routes should only parse requests and format responses.

Mistake 3: Raw Database Rows

Repositories returning database-specific types:

repositories/badUserRepository.js
// BAD: Returns raw database rows
async findById(id) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id]);
}

This leaks database details to the service layer. Always map to domain models.

Mistake 4: Cross-Layer Dependencies

Services accessing HTTP-specific objects:

services/badUserService.js
// BAD: Service knows about req/res
async registerUser(req, res) {
const { email } = req.body; // Service shouldn't know about HTTP
}

Services should work with plain data, not request/response objects.

Summary

The biggest difference between hobby projects and production Express.js applications isn’t features—it’s architecture discipline.

Layered architecture gives you:

  1. Testability: Unit test services without a database
  2. Maintainability: Changes isolated to specific layers
  3. Reusability: Service methods can be called from anywhere
  4. Clarity: Each layer has one responsibility

The pattern is simple: routes stay thin, business logic lives in services, and repositories handle data access. But simple doesn’t mean easy—it requires discipline to maintain this separation as your application grows.

Start with the layers from day one. Your future self will thank you when you need to swap databases, add features, or debug issues at 2 AM.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments