8.1 Why Structure Matters
A small Express app fits in one file. A real one doesn't.
The moment your application grows past a handful of routes, the single-file approach starts fighting you. Routes tangle together. Middleware ordering becomes hard to reason about. You spend more time finding code than writing it. And when a new developer joins, they have no map — just a 2,000-line app.js and a vague sense that everything is connected to everything.
Project structure is the answer to this. Not because there's one correct layout, but because a consistent structure makes the codebase navigable, testable, and modifiable. It gives every concern a home, and it makes the boundaries between concerns visible.
Express has no opinion on structure. That's by design — it's the same philosophy that makes the framework flexible. But the absence of a mandate doesn't mean the absence of patterns. Over the years, the Express community has converged on a few approaches that work. This topic covers them.
8.2 The Express Generator
The fastest way to see a working structure is to let Express scaffold one for you.
The express-generator package creates a starter application with a directory layout, a few sample routes, and configuration already wired up.
npx express-generator my-app
cd my-app
npm install
The generated structure looks like this:
my-app/
├── app.js
├── package.json
├── bin/
│ └── www
├── public/
│ ├── images/
│ ├── javascripts/
│ └── stylesheets/
├── routes/
│ ├── index.js
│ └── users.js
└── views/
├── error.pug
├── index.pug
└── layout.pug
Each piece has a purpose:
app.js— the application setup. Middleware, view engine configuration, route mounting. The generated version usesexpress.Router()instances from theroutes/directory.bin/www— the executable entry point. It creates the HTTP server, sets the port, and handles server-level errors. Separating this fromapp.jsmeans the application object can be imported and tested without starting a server.public/— static assets served byexpress.static.routes/— route modules, one per resource or concern.views/— templates.
The generator defaults to Pug for templating, but you can specify a different engine:
npx express-generator --view=ejs my-app
What the Generator Gets Right
The separation between app.js and bin/www is the most valuable part of the scaffold. It enforces a distinction that pays off later: the application is not the server. The application is a request handler. The server is what listens on a port and hands requests to the handler.
This separation makes testing dramatically easier. You can import app in a test file, pass it requests through a library like supertest, and never open a network port.
What the Generator Doesn't Give You
The scaffold is a starting point, not an architecture. It doesn't tell you where to put business logic, how to organize database access, or how to handle validation. Those decisions are yours. The generated routes/ files contain route definitions and handler logic mixed together, which works for small apps but breaks down as complexity grows.
8.3 Structuring by Concern
The generator's layout works until it doesn't. As the app grows, routes/ fills up with files that each do too much — routing, validation, business logic, database access, response formatting. The route file becomes a mini-application of its own, and the same problems that plagued the single app.js return at a smaller scale.
The solution is to separate layers by responsibility. This is the core idea behind MVC and its variants.
The Layers
A typical layered Express application has these concerns:
- Routes — map HTTP methods and paths to controller functions. Should be declarative and thin.
- Controllers — handle the request, call into services or models, and send the response. Contain HTTP-specific logic.
- Services (optional but recommended) — business logic. Independent of HTTP. Testable in isolation.
- Models / Data Access — interact with the database or external APIs.
- Middleware — cross-cutting concerns: authentication, validation, logging, error handling.
The flow looks like this:
Request → Route → Middleware → Controller → Service → Model → Database
↓
Response ← Controller ← Service ← Model
Each layer knows only about the layer beneath it. Controllers don't know about the database. Services don't know about HTTP. Models don't know about requests.
A Directory Structure
Putting this into practice:
my-app/
├── app.js
├── bin/
│ └── www
├── config/
│ ├── database.js
│ └── index.js
├── controllers/
│ ├── userController.js
│ └── authController.js
├── middleware/
│ ├── auth.js
│ ├── errorHandler.js
│ └── validate.js
├── models/
│ ├── User.js
│ └── Post.js
├── routes/
│ ├── index.js
│ ├── userRoutes.js
│ └── authRoutes.js
├── services/
│ ├── userService.js
│ └── authService.js
├── utils/
│ ├── logger.js
│ └── helpers.js
├── public/
├── views/
└── tests/
├── unit/
└── integration/
This isn't the only correct structure, but it's a common one. The important thing is that each directory has a clear purpose, and a developer can guess where something lives without searching.
How the Layers Connect
A route file stays thin:
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { validateCreateUser } = require('../middleware/validate');
router.get('/', userController.listUsers);
router.get('/:id', userController.getUser);
router.post('/', validateCreateUser, userController.createUser);
module.exports = router;
The controller handles HTTP concerns:
// controllers/userController.js
const userService = require('../services/userService');
exports.listUsers = async (req, res, next) => {
try {
const users = await userService.getAllUsers();
res.json(users);
} catch (err) {
next(err);
}
};
exports.getUser = async (req, res, next) => {
try {
const user = await userService.getUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
} catch (err) {
next(err);
}
};
exports.createUser = async (req, res, next) => {
try {
const user = await userService.createUser(req.body);
res.status(201).json(user);
} catch (err) {
next(err);
}
};
The service holds the business logic:
// services/userService.js
const User = require('../models/User');
exports.getAllUsers = async () => {
return User.find().select('-password');
};
exports.getUserById = async (id) => {
return User.findById(id).select('-password');
};
exports.createUser = async (data) => {
const user = new User(data);
return user.save();
};
The model handles data access:
// models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
name: { type: String, required: true },
}, { timestamps: true });
module.exports = mongoose.model('User', userSchema);
Notice the pattern. Each file has a narrow job. The route file doesn't know how users are created. The controller doesn't know how users are stored. The service doesn't know what HTTP looks like. When something breaks, you know where to look.
An Express 5 simplification: since rejected promises are forwarded to error handlers automatically, the try/catch wrappers in the controllers above become optional. You could write:
exports.listUsers = async (req, res) => {
const users = await userService.getAllUsers();
res.json(users);
};
If getAllUsers throws, Express 5 catches the rejection and routes it to your error handler. The explicit try/catch is still useful if you want to log at the controller level or handle specific errors differently, but it's no longer mandatory for correctness.
8.4 Modular Routers in Practice
We covered express.Router() in Topic 3, but it's worth seeing how routers fit into a real structure.
The pattern is: each resource gets its own router, and the main application mounts them under prefixes.
// app.js
const express = require('express');
const userRoutes = require('./routes/userRoutes');
const authRoutes = require('./routes/authRoutes');
const postRoutes = require('./routes/postRoutes');
const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/posts', postRoutes);
// Error handler last
app.use(require('./middleware/errorHandler'));
module.exports = app;
The routers themselves don't know about the /api prefix — that's applied by the parent. This means you can reorganize the API surface without touching router internals. You can move /api/users to /v2/users by changing one line in app.js.
Nested Routers for Complex Resources
For a resource with sub-resources — posts with comments, for example — you can nest routers:
// routes/postRoutes.js
const express = require('express');
const router = express.Router();
const postController = require('../controllers/postController');
const commentRoutes = require('./commentRoutes');
router.get('/', postController.listPosts);
router.get('/:postId', postController.getPost);
router.use('/:postId/comments', commentRoutes);
module.exports = router;
// routes/commentRoutes.js
const express = require('express');
const router = express.Router({ mergeParams: true });
const commentController = require('../controllers/commentController');
router.get('/', commentController.listComments);
router.post('/', commentController.createComment);
module.exports = router;
The mergeParams: true option is the key detail here. Without it, the comment router wouldn't have access to :postId from the parent route. With it, req.params.postId is available inside the comment handlers.
This is how Express scales: small, focused routers composed into larger structures. Each router is testable in isolation, and the composition happens at the mounting layer.
8.5 Common Patterns and Anti-Patterns
Put Business Logic in Services, Not Controllers
Controllers should be thin. They translate HTTP into service calls and service results back into HTTP responses. If a controller is 200 lines long, the logic probably belongs in a service.
The test: could you call this logic from a CLI tool or a background job without dragging HTTP along? If not, it's too coupled to the web layer.
Don't Put Logic in Templates
We covered this in Topic 6, but it's worth repeating in the context of structure. Templates present data. They don't compute it. If you're writing complex conditionals or loops inside a template, the data should be prepared in the controller or service.
Validate at the Boundary
Input validation should happen in middleware, before the request reaches the controller. This keeps validation consistent and makes it obvious where the rules live.
// middleware/validate.js
const { z } = require('zod');
const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().trim().max(100),
});
exports.validateCreateUser = (req, res, next) => {
const result = createUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
req.body = result.data; // Replace with validated data
next();
};
Centralize Error Handling
Every controller calls next(err) for unexpected errors, and a single error handler at the application level formats the response. This keeps error formatting consistent and means you change it in one place.
// middleware/errorHandler.js
const logger = require('../utils/logger');
module.exports = (err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
const status = err.status || 500;
if (process.env.NODE_ENV === 'development') {
return res.status(status).json({
error: err.message,
stack: err.stack,
});
}
res.status(status).json({
error: status === 500 ? 'Internal Server Error' : err.message,
});
};
Anti-Pattern: The Fat Route File
The most common anti-pattern in Express is the route file that does everything. It defines routes, validates input, queries the database, formats responses, and handles errors — all in one place. This works for two routes. It collapses at twenty.
The fix is the layered approach described above. Move validation to middleware, database access to models, business logic to services, and HTTP handling to controllers.
Anti-Pattern: Magic Middleware Order
Middleware order is logic. It should be visible and intentional. If your middleware ordering depends on subtle interactions that aren't documented, the application is fragile. Group related middleware together, comment the ordering when it matters, and keep the pipeline readable.
Anti-Pattern: Business Logic in Middleware
Middleware is for cross-cutting concerns: authentication, logging, validation, rate limiting. It's not for business logic. If a middleware function is making decisions about what the user is allowed to do based on domain rules, that logic probably belongs in a service called by the controller.
8.6 Configuration Management
Configuration deserves its own corner of the structure. Database connections, API keys, feature flags — these shouldn't be scattered across the codebase.
A config/ directory centralizes them:
// config/index.js
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3000,
env: process.env.NODE_ENV || 'development',
database: {
url: process.env.DATABASE_URL,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
},
};
Then import from config wherever you need a value:
const config = require('../config');
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
This makes it obvious what configuration the app depends on. It also makes it trivial to mock config values in tests.