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:
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.
+-----------------------------------------------------+| 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.
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.
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.
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:
| Concern | Hobby Architecture | Layered Architecture |
|---|---|---|
| Testing | Integration tests only, slow | Unit tests for logic, fast |
| Database change | Rewrite all routes | Update repository only |
| Business rule change | Find all routes, fix bugs | One service method |
| New feature | Copy-paste logic | Reuse service methods |
| Debugging | Where 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:
// BAD: Service does nothing usefulasync 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:
// BAD: Route doing validation that belongs in servicerouter.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:
// BAD: Returns raw database rowsasync 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:
// BAD: Service knows about req/resasync 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:
- Testability: Unit test services without a database
- Maintainability: Changes isolated to specific layers
- Reusability: Service methods can be called from anywhere
- 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