Episode 3 — NodeJS MongoDB Backend Architecture / 3.13 — Production Project Structure
3.13.a --- Application Structure
In one sentence: A well-organised project structure is the difference between a codebase that welcomes new developers and one that makes them run --- it enforces separation of concerns, reduces merge conflicts, and makes every file findable in seconds.
Navigation: <- 3.13 Overview | 3.13.b --- File Naming & Git Configuration ->
1. Why project structure matters
Most tutorials dump everything into one file. That works for a 50-line demo --- it collapses for anything real.
| Problem | What happens without structure |
|---|---|
| Maintainability | A single 2,000-line app.js becomes impossible to debug or extend |
| Scalability | Adding a new feature means touching 10 unrelated files |
| Onboarding | New developers spend days figuring out where things live |
| Merge conflicts | Everyone edits the same files, Git conflicts multiply |
| Testing | Tightly coupled code cannot be unit-tested in isolation |
| Code review | Reviewers cannot tell what a PR changes at a glance |
A good structure is not about being clever --- it is about being predictable. If a developer can guess where a file lives without searching, the structure is working.
2. Feature-based vs layer-based organisation
There are two dominant strategies for organising backend code:
Layer-based (by technical role)
src/
├── controllers/
│ ├── user.controller.js
│ ├── post.controller.js
│ └── comment.controller.js
├── models/
│ ├── user.model.js
│ ├── post.model.js
│ └── comment.model.js
├── routes/
│ ├── user.routes.js
│ ├── post.routes.js
│ └── comment.routes.js
└── services/
├── user.service.js
├── post.service.js
└── comment.service.js
Feature-based (by domain)
src/
├── users/
│ ├── user.controller.js
│ ├── user.model.js
│ ├── user.routes.js
│ ├── user.service.js
│ └── user.validator.js
├── posts/
│ ├── post.controller.js
│ ├── post.model.js
│ ├── post.routes.js
│ ├── post.service.js
│ └── post.validator.js
└── comments/
├── comment.controller.js
├── comment.model.js
├── comment.routes.js
└── comment.service.js
Comparison
| Aspect | Layer-based | Feature-based |
|---|---|---|
| Navigation | Jump between folders for one feature | Everything for one feature in one place |
| Scaling | Folders grow very large (50+ files) | Each feature folder stays small |
| Coupling | Harder to see inter-feature dependencies | Dependencies are explicit imports |
| Deletion | Removing a feature means editing many folders | Delete one folder |
| Learning curve | Familiar to most Express tutorials | Slightly more setup, but scales better |
| Best for | Small-to-medium APIs (< 10 resources) | Medium-to-large APIs (10+ resources) |
Recommendation: Start with layer-based for learning and small projects. Switch to feature-based when the project grows beyond 8-10 resources. Many production apps use a hybrid --- layer-based for shared concerns (middleware, config, utils) and feature-based for domain logic.
3. Standard Express project structure (annotated)
This is the layer-based structure used across most Express tutorials and production starter kits:
project-root/
│
├── src/
│ ├── config/ --- Database connections, env parsing, constants
│ │ ├── db.js --- MongoDB/Mongoose connection logic
│ │ ├── index.js --- Centralized config export
│ │ └── constants.js --- App-wide magic values (roles, status codes)
│ │
│ ├── controllers/ --- Request handlers (thin: parse request, call service, send response)
│ │ ├── auth.controller.js
│ │ ├── user.controller.js
│ │ └── post.controller.js
│ │
│ ├── middleware/ --- Custom Express middleware
│ │ ├── auth.middleware.js --- JWT verification, role checks
│ │ ├── error.middleware.js --- Global error handler
│ │ ├── validate.middleware.js --- Request validation runner
│ │ └── upload.middleware.js --- Multer file upload config
│ │
│ ├── models/ --- Mongoose schemas and model exports
│ │ ├── user.model.js
│ │ ├── post.model.js
│ │ └── comment.model.js
│ │
│ ├── routes/ --- Route definitions (URL -> controller mapping)
│ │ ├── index.js --- Aggregates all route files
│ │ ├── auth.routes.js
│ │ ├── user.routes.js
│ │ └── post.routes.js
│ │
│ ├── services/ --- Business logic (testable, framework-agnostic)
│ │ ├── auth.service.js
│ │ ├── user.service.js
│ │ └── post.service.js
│ │
│ ├── utils/ --- Pure helper functions
│ │ ├── ApiError.js
│ │ ├── ApiResponse.js
│ │ ├── asyncHandler.js
│ │ └── logger.js
│ │
│ ├── validators/ --- Joi/Zod validation schemas
│ │ ├── auth.validator.js
│ │ ├── user.validator.js
│ │ └── post.validator.js
│ │
│ └── app.js --- Express app setup (middleware, routes, error handler)
│
├── server.js --- Entry point: connects DB, starts HTTP listener
├── package.json
├── .env
├── .env.example
├── .gitignore
└── README.md
4. Entry point: server.js vs app.js
This split is one of the most important patterns in production Express apps.
app.js --- Express configuration
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const routes = require('./routes');
const { errorHandler } = require('./middleware/error.middleware');
const app = express();
// --- Global middleware ---
app.use(helmet()); // Security headers
app.use(cors()); // Cross-origin requests
app.use(morgan('dev')); // Request logging
app.use(express.json({ limit: '16kb' })); // JSON body parser
app.use(express.urlencoded({ extended: true }));
// --- API routes ---
app.use('/api/v1', routes);
// --- Health check ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});
// --- Global error handler (must be last) ---
app.use(errorHandler);
module.exports = app;
server.js --- Starts the listener
// server.js
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/db');
const config = require('./src/config');
const startServer = async () => {
try {
// Connect to MongoDB
await connectDB();
console.log('Database connected successfully');
// Start listening
app.listen(config.port, () => {
console.log(`Server running on port ${config.port} in ${config.env} mode`);
});
} catch (error) {
console.error('Failed to start server:', error.message);
process.exit(1);
}
};
startServer();
Why separate them?
| Concern | app.js | server.js |
|---|---|---|
| Responsibility | Express configuration | Process lifecycle |
| Testability | Import app in tests without starting a server | Only runs in production |
| Portability | Can be wrapped by serverless handlers (AWS Lambda) | Specific to HTTP listener |
| Database | No database knowledge | Handles connection |
| Port binding | No port knowledge | Binds to process.env.PORT |
This separation means your test suite can require('./src/app') and use supertest without ever binding to a port or connecting to a real database.
5. Separation of concerns at file level
Each layer has one job:
Request Flow:
Client -> Route -> Middleware -> Controller -> Service -> Model -> Database
|
Client <- Response <- Controller <- Service <-----+
| Layer | Responsibility | What it should NOT do |
|---|---|---|
| Route | Map URL + HTTP method to controller | Contain business logic |
| Middleware | Cross-cutting concerns (auth, logging, validation) | Query the database directly |
| Controller | Parse request, call service, format response | Contain business rules or DB queries |
| Service | Business logic, orchestration | Know about req/res objects |
| Model | Data schema, instance methods, statics | Contain Express-specific code |
| Validator | Input validation rules | Throw HTTP errors |
| Utils | Pure helper functions | Have side effects or state |
Example: Creating a user
// routes/user.routes.js --- ROUTING ONLY
const router = require('express').Router();
const { createUser, getUsers } = require('../controllers/user.controller');
const { validateCreateUser } = require('../validators/user.validator');
const { validate } = require('../middleware/validate.middleware');
router.post('/', validateCreateUser, validate, createUser);
router.get('/', getUsers);
module.exports = router;
// controllers/user.controller.js --- THIN CONTROLLER
const userService = require('../services/user.service');
const ApiResponse = require('../utils/ApiResponse');
const asyncHandler = require('../utils/asyncHandler');
const createUser = asyncHandler(async (req, res) => {
const user = await userService.createUser(req.body);
res.status(201).json(new ApiResponse(201, user, 'User created successfully'));
});
const getUsers = asyncHandler(async (req, res) => {
const users = await userService.getAllUsers(req.query);
res.status(200).json(new ApiResponse(200, users, 'Users fetched successfully'));
});
module.exports = { createUser, getUsers };
// services/user.service.js --- BUSINESS LOGIC
const User = require('../models/user.model');
const ApiError = require('../utils/ApiError');
const createUser = async (userData) => {
const existingUser = await User.findOne({ email: userData.email });
if (existingUser) {
throw new ApiError(409, 'User with this email already exists');
}
const user = await User.create(userData);
// Remove password from response
user.password = undefined;
return user;
};
const getAllUsers = async (query) => {
const { page = 1, limit = 10 } = query;
const users = await User.find()
.select('-password')
.skip((page - 1) * limit)
.limit(Number(limit));
return users;
};
module.exports = { createUser, getAllUsers };
6. Index files for clean imports
Index files aggregate exports from a folder, giving consumers a single import path.
Without index files
const { createUser } = require('../controllers/user.controller');
const { createPost } = require('../controllers/post.controller');
const { createComment } = require('../controllers/comment.controller');
With index files
// controllers/index.js
module.exports = {
...require('./user.controller'),
...require('./post.controller'),
...require('./comment.controller'),
};
// Now import from the folder
const { createUser, createPost, createComment } = require('../controllers');
Route index file (most common and most useful)
// routes/index.js
const router = require('express').Router();
const authRoutes = require('./auth.routes');
const userRoutes = require('./user.routes');
const postRoutes = require('./post.routes');
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
router.use('/posts', postRoutes);
module.exports = router;
This gives app.js a single line: app.use('/api/v1', require('./routes')).
7. Real example: complete structure for a blog API
blog-api/
│
├── src/
│ ├── config/
│ │ ├── index.js --- Exports { port, mongoUri, jwtSecret, env }
│ │ ├── db.js --- mongoose.connect() with retry logic
│ │ └── constants.js --- USER_ROLES, POST_STATUS, PAGINATION_DEFAULTS
│ │
│ ├── controllers/
│ │ ├── auth.controller.js --- register, login, logout, refreshToken
│ │ ├── user.controller.js --- getProfile, updateProfile, deleteAccount
│ │ ├── post.controller.js --- createPost, getPosts, getPost, updatePost, deletePost
│ │ └── comment.controller.js --- addComment, getComments, deleteComment
│ │
│ ├── middleware/
│ │ ├── auth.middleware.js --- verifyToken, requireRole('admin')
│ │ ├── error.middleware.js --- Global error handler
│ │ ├── validate.middleware.js --- Runs Joi/Zod validation
│ │ ├── upload.middleware.js --- Multer config for post images
│ │ └── rateLimiter.middleware.js --- express-rate-limit config
│ │
│ ├── models/
│ │ ├── user.model.js --- name, email, password (hashed), role, avatar
│ │ ├── post.model.js --- title, slug, content, author (ref), tags, status
│ │ └── comment.model.js --- text, author (ref), post (ref), createdAt
│ │
│ ├── routes/
│ │ ├── index.js --- Aggregates all route files under /api/v1
│ │ ├── auth.routes.js --- POST /register, POST /login, POST /logout
│ │ ├── user.routes.js --- GET /me, PATCH /me, DELETE /me
│ │ ├── post.routes.js --- Full CRUD with pagination and filtering
│ │ └── comment.routes.js --- Nested under /posts/:postId/comments
│ │
│ ├── services/
│ │ ├── auth.service.js --- Password hashing, JWT generation, token refresh
│ │ ├── user.service.js --- Profile CRUD, avatar upload handling
│ │ ├── post.service.js --- Post CRUD with slug generation, tag filtering
│ │ ├── comment.service.js --- Comment CRUD with post association
│ │ └── email.service.js --- Nodemailer config, send verification/reset emails
│ │
│ ├── utils/
│ │ ├── ApiError.js --- Custom error class with statusCode
│ │ ├── ApiResponse.js --- Standard success response wrapper
│ │ ├── asyncHandler.js --- Wraps async route handlers
│ │ ├── logger.js --- Winston logger configuration
│ │ └── generateSlug.js --- Converts title to URL-safe slug
│ │
│ ├── validators/
│ │ ├── auth.validator.js --- Register/login input schemas
│ │ ├── user.validator.js --- Profile update schemas
│ │ ├── post.validator.js --- Post create/update schemas
│ │ └── common.validator.js --- Shared rules (mongoId, pagination params)
│ │
│ └── app.js
│
├── public/ --- Static files (uploaded images)
│ └── uploads/
│ └── .gitkeep
│
├── tests/ --- Test files (mirrors src/ structure)
│ ├── unit/
│ │ └── services/
│ │ └── user.service.test.js
│ └── integration/
│ └── routes/
│ └── auth.routes.test.js
│
├── server.js
├── package.json
├── .env
├── .env.example
├── .gitignore
├── .eslintrc.js
├── .prettierrc
└── README.md
config/constants.js
// src/config/constants.js
const USER_ROLES = Object.freeze({
USER: 'user',
ADMIN: 'admin',
MODERATOR: 'moderator',
});
const POST_STATUS = Object.freeze({
DRAFT: 'draft',
PUBLISHED: 'published',
ARCHIVED: 'archived',
});
const PAGINATION = Object.freeze({
DEFAULT_PAGE: 1,
DEFAULT_LIMIT: 10,
MAX_LIMIT: 100,
});
module.exports = { USER_ROLES, POST_STATUS, PAGINATION };
config/db.js
// src/config/db.js
const mongoose = require('mongoose');
const config = require('./index');
const connectDB = async () => {
try {
const conn = await mongoose.connect(config.mongoUri);
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (error) {
console.error(`MongoDB connection error: ${error.message}`);
process.exit(1);
}
};
module.exports = connectDB;
8. Key takeaways
- Layer-based puts files by technical role; feature-based puts files by domain --- pick one and be consistent.
- Separate
app.jsfromserver.js--- configuration vs lifecycle. This enables testing and serverless deployment. - Thin controllers --- parse the request, delegate to a service, send the response. No business logic.
- Services own business logic --- they are framework-agnostic and testable in isolation.
- Index files clean up imports and give each folder a single public API.
- The annotated directory tree in this lesson is a starting template --- adapt it to your project's needs, not the other way around.
Explain-It Challenge
Explain without notes:
- Why should controllers never contain database queries directly?
- A teammate suggests putting everything in one
routes.jsfile "because it's simpler." Give three concrete problems that will appear as the project grows. - Draw the request flow from client to database and back, naming each layer the request passes through.
Navigation: <- 3.13 Overview | 3.13.b --- File Naming & Git Configuration ->