How to structure a large Express.js application?
Express.js application structure - splitting code into layered folders that separate routing, business logic, data access, and configuration so the app stays maintainable as it scales.
Theory
TL;DR
- Restaurant analogy: routes take orders at the door, controllers handle the basics, services do the complex cooking, models fetch from the pantry, middleware checks IDs on the way in.
- A flat
app.jswith all logic in one file works for 5 routes. At 50, you spend more time finding code than writing it. - Core split: routes stay thin (URL params only), controllers own req/res, services own business logic, models talk to the DB.
- Over 10 routes or 5 models: add layers now.
Folder structure at a glance
src/
├── server.js # Entry point: app.listen() only
├── app.js # Express setup, middleware, root router
├── config/
│ ├── index.js # Env vars with validation
│ └── database.js # DB connection
├── routes/
│ ├── index.js # Mounts all sub-routers
│ ├── users.routes.js
│ └── products.routes.js
├── controllers/
│ ├── users.controller.js
│ └── products.controller.js
├── services/
│ ├── users.service.js # Business logic
│ └── email.service.js
├── models/
│ ├── User.model.js
│ └── Product.model.js
├── middleware/
│ ├── auth.middleware.js
│ └── rateLimiter.middleware.js
├── validators/
│ └── users.validator.js
└── utils/
├── AppError.js
└── asyncHandler.jsapp.js and server.js: why the split
This trips people up the first time. app.js sets up Express, registers middleware, mounts routers, and exports the app object. It never calls app.listen(). That job belongs to server.js.
The reason is testing. Import app.js in a supertest suite and you fire HTTP requests without binding a real port. One concern per file, no conflict.
// src/app.js
const express = require('express');
const routes = require('./routes');
const { notFound, errorHandler } = require('./middleware/error.middleware');
const app = express();
app.use(express.json({ limit: '10kb' }));
app.use('/api/v1', routes);
app.use(notFound);
app.use(errorHandler); // always last
module.exports = app;
// src/server.js
const app = require('./app');
const { connectDB } = require('./config/database');
async function start() {
await connectDB();
app.listen(process.env.PORT || 3000, () =>
console.log(`Server on ${process.env.PORT || 3000}`)
);
}
start().catch(console.error);GET /api/v1/users resolves cleanly. Tests import app.js and never touch server.js.
How a request moves through layers
Express matches paths using a trie (radix tree), so GET /api/v1/users/123 resolves in O(k) time where k is the number of path segments. From there the request travels a predictable route: the router picks the handler, the controller reads req.params and calls the service, the service runs business logic and queries the model, the response flows back up.
Errors go the other direction. They bubble back through the middleware stack in reverse order. That is why error-handling middleware sits last in app.js.
| Layer | Responsibility |
|---|---|
| Routes | URL to handler mapping, per-router middleware |
| Controllers | req/res handling, delegates to services |
| Services | Business logic, external API calls |
| Models | DB schemas and queries (Mongoose, Prisma) |
| Middleware | Auth, rate limiting, error handling |
| Config | Env vars validated at startup |
Controllers handle HTTP-specific concerns: status codes, JSON serialization, reading headers. Services know nothing about HTTP. That separation makes the same userService.findById() callable from a REST endpoint, a WebSocket handler, and a background job without modification.
When to add layers
Under 10 routes with one developer: a single app.js is fine. Structure adds navigation overhead before there is anything worth navigating.
Between 10 and 50 routes with a small team: add routes, controllers, and models. Skip a dedicated services layer if the logic fits in controllers without growing past 30 lines per function.
Over 50 routes with a growing team: full stack. Services, validators, and a routes/index.js that mounts everything. Otherwise app.js becomes a 500-line import list, which is the top Express complaint on Stack Overflow.
Splitting into microservices later: domain folders per bounded context (billing-domain/routes, user-domain/services) with shared utilities in core/. MedusaJS uses this exact layout to power its e-commerce platform across thousands of production stores.
Common mistakes
1. Business logic in routes:
// wrong: email logic leaks into the route handler
router.post('/users', async (req, res) => {
const user = await User.create(req.body);
await sendEmail(req.body.email); // should not be here
res.status(201).json(user);
});
// right: route is one line
router.post('/users', validateCreateUser, createUser);Routes bloat to 100+ lines fast. You cannot unit test them without mocking a full HTTP request.
2. Global auth middleware:
// wrong: blocks /login, /health, and every public route
app.use(authenticate);
// right: protect only the routes that need it
usersRouter.use(authenticate);I have seen this exact pattern cause "getting 401 on my health check" questions in Express forums more times than I can count. It is always this.
3. No index file in routes:
// wrong: app.js imports 20 files directly
app.use('/users', require('./routes/users.routes'));
// ... 18 more lines
// right: routes/index.js handles everything
app.use('/api/v1', require('./routes'));4. Direct model calls in controllers:
// wrong: model call inside controller, no reuse
exports.getUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
// right: controller calls service, service calls model
const user = await usersService.findById(req.params.id);When a cron job needs the same query, you copy-paste User.findById into a script. Then you have two places to update when the schema changes.
5. Config inline in app.js:
// wrong: crashes silently on missing env var
app.listen(process.env.PORT || 3000);
// right: validate at startup, fail loudly before deployment
const config = require('./config'); // throws if PORT missing in prod
app.listen(config.port);Real-world usage
- NestJS: enforces this pattern with
@Controllerand@Injectabledecorators, runs on Express by default, 100k+ GitHub stars. - MedusaJS:
src/api/routes,src/services,src/models, 1000+ production stores. - LoopBack (IBM):
controllersandrepositoriesas first-class framework concepts. - FeathersJS: services are the central primitive, REST and WebSocket handlers are generated from them automatically.
Follow-up questions
Q: Why separate services from controllers if the business logic is simple?
A: Controllers have HTTP-specific knowledge: status codes, response formatting, reading headers. Services have none of that. When you add a Kafka consumer or a CLI script later, the service is already portable without any changes.
Q: How do you handle circular dependencies between services?
A: Pass services as arguments to factory functions rather than requiring them at the top of the file. Running madge --circular src/ before merging catches cycles before they surface as undefined errors at runtime.
Q: How do you version routes in a large app?
A: Mount parallel routers: app.use('/api/v1', v1Routes) and app.use('/api/v2', v2Routes). Clients migrate on their own schedule. Feature flags let you A/B test v2 behavior before fully promoting it.
Q: What is the testing strategy per layer?
A: Unit tests mock the service inside controller tests. Integration tests use supertest against a real test database. Contract tests with Pact verify that clients and servers agree on the API shape between deploys.
Q (Senior): In a 100-service monorepo, how do domain boundaries affect folder structure?
A: Each bounded context gets its own subfolder: billing-domain/routes, user-domain/services. Shared utilities live in core/. Enforce boundaries with eslint-plugin-import rules that block cross-domain direct imports. This prevents the "big ball of mud" that large monoliths grow into when teams skip this step, which is exactly what Uber dealt with when splitting their early monolith.
Examples
Basic: thin route, controller, service
// routes/users.routes.js
const router = require('express').Router();
const { getUser } = require('../controllers/users.controller');
const { authenticate } = require('../middleware/auth.middleware');
router.get('/:id', authenticate, getUser); // URL mapping only
module.exports = router;
// controllers/users.controller.js
const asyncHandler = require('../utils/asyncHandler');
const usersService = require('../services/users.service');
const AppError = require('../utils/AppError');
exports.getUser = asyncHandler(async (req, res) => {
const user = await usersService.findById(req.params.id);
if (!user) throw new AppError('User not found', 404);
res.json({ success: true, data: user });
});
// services/users.service.js
const User = require('../models/User.model');
exports.findById = async (id) => User.findById(id);GET /api/v1/users/123 returns { success: true, data: { name: 'Alice' } }. The service has no knowledge of HTTP. Call usersService.findById from anywhere in the codebase.
Intermediate: product creation with validation and notifications
// routes/products.routes.js
router.post('/', authenticate, validateCreateProduct, createProduct);
// controllers/products.controller.js
const asyncHandler = require('../utils/asyncHandler');
const productsService = require('../services/products.service');
const emailService = require('../services/email.service');
exports.createProduct = asyncHandler(async (req, res) => {
const product = await productsService.create({
...req.body,
tenantId: req.user.tenantId, // tenant isolation from auth middleware
});
await emailService.notifyTeam({ productId: product.id });
res.status(201).json({ success: true, data: product });
});
// services/products.service.js
const Product = require('../models/Product.model');
exports.create = async (data) => {
const product = new Product(data);
await product.save();
return product.populate('category'); // eager load relation
};The validator runs before the controller. Tenant ID comes from auth middleware. The email call is a service, not inline code. Each piece is independently testable and replaceable.
Advanced: transactional order with decoupled inventory update
// services/orders.service.js
const mongoose = require('mongoose');
const Order = require('../models/Order.model');
const eventEmitter = require('../utils/eventEmitter');
exports.createOrder = async (orderData) => {
const session = await mongoose.startSession();
session.startTransaction();
try {
const [order] = await Order.create([orderData], { session });
eventEmitter.emit('order.created', { id: order.id }); // decoupled
await session.commitTransaction();
return order;
} catch (err) {
await session.abortTransaction();
throw err;
} finally {
session.endSession();
}
};
// listeners/inventory.listener.js - registered once at startup
const eventEmitter = require('../utils/eventEmitter');
const inventoryService = require('../services/inventory.service');
eventEmitter.on('order.created', async ({ id }) => {
await inventoryService.reserveStock(id); // async, does not block order response
});The order saves atomically inside the transaction. Inventory reservation happens after the commit without blocking the response. This pattern handles high-throughput order processing and is used in Shopify-style backends that process thousands of transactions per second.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.