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.

ProblemWhat happens without structure
MaintainabilityA single 2,000-line app.js becomes impossible to debug or extend
ScalabilityAdding a new feature means touching 10 unrelated files
OnboardingNew developers spend days figuring out where things live
Merge conflictsEveryone edits the same files, Git conflicts multiply
TestingTightly coupled code cannot be unit-tested in isolation
Code reviewReviewers 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

AspectLayer-basedFeature-based
NavigationJump between folders for one featureEverything for one feature in one place
ScalingFolders grow very large (50+ files)Each feature folder stays small
CouplingHarder to see inter-feature dependenciesDependencies are explicit imports
DeletionRemoving a feature means editing many foldersDelete one folder
Learning curveFamiliar to most Express tutorialsSlightly more setup, but scales better
Best forSmall-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?

Concernapp.jsserver.js
ResponsibilityExpress configurationProcess lifecycle
TestabilityImport app in tests without starting a serverOnly runs in production
PortabilityCan be wrapped by serverless handlers (AWS Lambda)Specific to HTTP listener
DatabaseNo database knowledgeHandles connection
Port bindingNo port knowledgeBinds 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 <-----+
LayerResponsibilityWhat it should NOT do
RouteMap URL + HTTP method to controllerContain business logic
MiddlewareCross-cutting concerns (auth, logging, validation)Query the database directly
ControllerParse request, call service, format responseContain business rules or DB queries
ServiceBusiness logic, orchestrationKnow about req/res objects
ModelData schema, instance methods, staticsContain Express-specific code
ValidatorInput validation rulesThrow HTTP errors
UtilsPure helper functionsHave 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

  1. Layer-based puts files by technical role; feature-based puts files by domain --- pick one and be consistent.
  2. Separate app.js from server.js --- configuration vs lifecycle. This enables testing and serverless deployment.
  3. Thin controllers --- parse the request, delegate to a service, send the response. No business logic.
  4. Services own business logic --- they are framework-agnostic and testable in isolation.
  5. Index files clean up imports and give each folder a single public API.
  6. 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:

  1. Why should controllers never contain database queries directly?
  2. A teammate suggests putting everything in one routes.js file "because it's simpler." Give three concrete problems that will appear as the project grows.
  3. 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 ->