Avancé20 min de lecture

Project Structure (MVC)

Organize your Express app with the Model-View-Controller pattern for clean, maintainable, and scalable code.

Why Structure Matters

When you are learning Express, every tutorial puts everything in a single app.js file: routes, middleware, database connections, business logic, error handling. For a tutorial with 3 routes, this is fine. For a real application, it is a disaster.

As your application grows — 10 routes become 50, one model becomes 15, simple validation becomes complex business rules — a single file becomes unmanageable. You spend more time scrolling through 2,000 lines of code looking for the function you need than actually writing code. Multiple developers editing the same file creates constant merge conflicts. Testing is nearly impossible because everything is tangled together.

Good project structure provides:

  1. Separation of concerns — Each file has one responsibility. Database queries live in models, request handling lives in controllers, URL mapping lives in routes. When something breaks, you know exactly where to look.
  2. Discoverability — A new developer joins the team, looks at the folder structure, and immediately understands where to find things. Need the user authentication logic? Check controllers/authController.js. Need the database schema? Check models/User.js.
  3. Team collaboration — Two developers can work on different features without touching the same files. One builds the user controller while another builds the product controller. No merge conflicts.
  4. Scalability — Adding a new resource (products, orders, reviews) follows the same pattern: create a model, create a controller, create routes, register them. The structure scales linearly.
  5. Testability — When your database logic is isolated in models and your business logic is isolated in controllers, you can unit test each piece independently. Mock the model to test the controller. Mock the database to test the model.

The most popular structure for Express applications is the MVC pattern (Model-View-Controller), adapted for APIs where the "View" is the JSON response rather than an HTML template.

The MVC Pattern

MVC is an architectural pattern that divides your application into three interconnected layers, each with a distinct responsibility. It was invented in the 1970s for desktop applications and has been adapted for web development in every major framework: Ruby on Rails (Ruby), Django (Python), Laravel (PHP), Spring (Java), and Express (Node.js).

Model — The Data Layer Models define the structure of your data and contain all database interaction logic. In a Mongoose-based app, a model defines the schema (what fields a document has, their types, validation rules) and provides methods for creating, reading, updating, and deleting records. Models know nothing about HTTP, requests, or responses — they only know about data.

Example responsibilities: Define a User schema with name, email, and password fields. Provide a method to find a user by email. Hash the password before saving. Validate that the email is unique.

View — The Presentation Layer In traditional web apps (using template engines like EJS, Pug, or Handlebars), views are HTML templates that display data. In modern API development, the "view" is simply the JSON response that the controller sends. Some teams skip this layer entirely for APIs, while others create serializer or transformer functions that format the data before sending it.

Example responsibilities: Transform a user document into a clean JSON object (strip the password, format dates, include computed fields).

Controller — The Business Logic Layer Controllers are the glue between the HTTP world and the data world. They receive the incoming request, extract parameters and body data, call the appropriate model methods, handle errors, and send the response. Controllers should be "thin" — they orchestrate rather than implement. Complex business logic belongs in a service layer or in the models themselves.

Example responsibilities: Parse req.body for name and email. Call User.create(). Handle duplicate email errors. Send a 201 response with the new user.

Route — The URL Mapper Routes connect URLs and HTTP methods to controller functions. They are the entry point for every request. Routes should contain zero logic — just mapping. GET /users maps to userController.getAll. POST /users maps to userController.create. Keep them declarative and clean.

Project Directory Structure

text
my-express-app/
├── src/
│   ├── config/                  # Configuration files
│   │   ├── db.js                # Database connection setup
│   │   └── config.js            # Environment variables & app config
│   │
│   ├── controllers/             # Request handlers (business logic)
│   │   ├── authController.js    # Login, signup, logout
│   │   ├── userController.js    # CRUD operations for users
│   │   └── postController.js    # CRUD operations for posts
│   │
│   ├── middleware/              # Custom Express middleware
│   │   ├── auth.js              # JWT verification, protect routes
│   │   ├── errorHandler.js      # Centralized error handling
│   │   ├── validate.js          # Request body validation
│   │   └── rateLimiter.js       # Rate limiting
│   │
│   ├── models/                  # Database schemas / models
│   │   ├── User.js              # User schema + methods
│   │   └── Post.js              # Post schema + methods
│   │
│   ├── routes/                  # Route definitions
│   │   ├── index.js             # Mounts all route modules
│   │   ├── authRoutes.js        # /api/auth/*
│   │   ├── userRoutes.js        # /api/users/*
│   │   └── postRoutes.js        # /api/posts/*
│   │
│   ├── utils/                   # Helper functions & classes
│   │   ├── AppError.js          # Custom error classes
│   │   ├── catchAsync.js        # Async handler wrapper
│   │   └── apiFeatures.js       # Filtering, sorting, pagination
│   │
│   ├── app.js                   # Express app setup (middleware, routes)
│   └── server.js                # Entry point (load config, connect DB, listen)
│
├── tests/                       # Test files mirroring src/ structure
│   ├── controllers/
│   └── models/
│
├── .env                         # Environment variables (NOT committed)
├── .env.example                 # Template for environment variables
├── .gitignore                   # Must include .env
├── package.json
└── README.md

Controllers

A controller is a collection of functions, each handling a specific route's logic. The key principle is one controller per resource (users, posts, comments, orders). Each controller exports standard CRUD functions that follow a predictable naming convention.

Here is the typical pattern for a controller function:

javascript
// controllers/userController.js
const User = require('../models/User');
const AppError = require('../utils/AppError');

exports.getAllUsers = async (req, res, next) => {
  const users = await User.find();
  res.status(200).json({
    status: 'success',
    results: users.length,
    data: { users }
  });
};

exports.getUserById = async (req, res, next) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    return next(new AppError('No user found with that ID', 404));
  }
  res.status(200).json({
    status: 'success',
    data: { user }
  });
};

exports.createUser = async (req, res, next) => {
  const newUser = await User.create(req.body);
  res.status(201).json({
    status: 'success',
    data: { user: newUser }
  });
};

Keep controllers thin. A controller should:

  1. Extract data from the request (req.params, req.body, req.query)
  2. Call model methods or service functions
  3. Format and send the response

What a controller should NOT do:

  • Directly write database queries (that belongs in the model)
  • Implement complex business rules (that belongs in a service layer)
  • Handle authentication logic (that belongs in middleware)
  • Format or validate input (that belongs in validation middleware)

This discipline keeps each controller function under 20 lines. When a controller starts growing beyond that, it is a sign that logic needs to be extracted into a service or utility module.

Putting It All Together

Understanding how the pieces connect is just as important as understanding each piece individually. Here is the complete flow of a request through an MVC Express application:

1. server.js — The Entry Point This file loads the configuration (dotenv), connects to the database, imports the Express app, and starts listening on a port. It is deliberately minimal — its only job is to boot the application.

javascript
require('dotenv').config();
const connectDB = require('./config/db');
const app = require('./app');
connectDB();
app.listen(process.env.PORT || 3000);

2. app.js — The Express Application This file creates the Express app, applies global middleware (JSON parsing, CORS, logging, rate limiting), mounts route modules on their base paths, and registers the error-handling middleware last.

javascript
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api/users', require('./routes/userRoutes'));
app.use(require('./middleware/errorHandler'));
module.exports = app;

3. Routes map URLs to controllers

javascript
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', validate(createUserSchema), userController.createUser);

4. Controllers call models and return responses The controller receives the request, calls User.find() or User.create(), and sends back a JSON response.

5. Models interact with the database The model defines the schema, runs the query, and returns the data to the controller.

Each piece is independent and testable. You can test models without HTTP, test controllers by mocking models, test routes with integration tests. This modularity is why MVC has been the dominant pattern for web applications for over a decade. Every new resource you add follows the exact same pattern: create a model file, a controller file, a routes file, and mount the routes in app.js.

In MVC, which layer interacts directly with the database?

Prêt à pratiquer ?

Crée ton compte gratuit pour accéder à l'éditeur de code interactif, lancer les défis et suivre ta progression.