Building Express.js project by Utilizing MVC Pattern

Introduction

Continuing from the previous article on getting started with Express.js: Express.js Basic CRUD Todo list, Express provides a convenient and elegant API for handling requests. However, as the scale grows, having all code such as routes, business logic, and data in one app.js is not ideal. This article introduces using the MVC architecture to separate code for better maintainability.

What is MVC

MVC (Model-View-Controller🔗) is a widely used architectural pattern that divides the logic of an application into three main parts:

  • Model: Responsible for handling data-related logic. Manages the business logic of application data and interacts with the database.
  • View: Responsible for displaying data. Manages the presentation of data and typically does not perform any business logic processing. Its role is to present data obtained from the Model in an appropriate format.
  • Controller: Responsible for handling user requests and deciding how to respond. Manages the Model and View, receives requests from the View, processes them through the Model, and then returns the results to the View. In simple terms, the Controller controls the flow of data and is responsible for the business logic of the application.

Through this approach, the MVC architecture achieves a clear separation of code, allowing different parts to focus on their specific functions and be maintained independently. This makes it easier for developers to develop, test, and maintain, especially as the application scales.

MVC Express
MVC Express

Understanding Existing Issues

For example, in the existing app.js: “Edit Todo Code Snippet by ID,” we find that all logic is mixed together, such as routes, business logic, and DB communication. Can we reference the MVC pattern to break down the code into different responsibilities for easier future management?

// Edit {id} Todo
app.put("/api/todos/:id", (req, res) => {
const targetId = req.params.id
const targetIndex = todos.findIndex(todo => todo.id === targetId);
const isTargetTodoExist = targetIndex !== -1
if (!isTargetTodoExist) {
return res.status(404).json({ error: "Todo not found" });
}
const updatedTodo = {
...todos[targetIndex],
title: req.body.title ?? todos[targetIndex].title,
isCompleted: req.body.isCompleted ?? todos[targetIndex].isCompleted
}
const newTodos = [
...todos.slice(0, targetIndex),
updatedTodo,
...todos.slice(targetIndex + 1),
];
todos = newTodos
res.json(updatedTodo)
})

1. Dividing Different Responsibility Code Files

For example, an API for todo items can be managed in separate folders for different responsibilities. Naming is not fixed; some may name them todos.model.js, todos.controller.js, etc., for easy differentiation of purposes:

.
├── routes/
│ └── todos.js
├── models/
│ └── todos.js
├── controllers/
│ └── todos.js
└── app.js

2. Routes

app.js
import todoRouter from './routes/todos.js';
app.use('/api/todos', todoRouter);

Additionally, initially register the routes related to routes/todos.js in app.js Middleware🔗. Specifically, use express.Router🔗, so that all todoRouter will be prefixed with the /api/todos path.

routes/todos.js
import todoController from '../controllers/todos.js';
const router = express.Router();
// Edit {id} Todo
router.put('/:id', todoController.editTodo); // /api/todos/:id
export default router;

3. Controllers

A simple object containing the editTodo method can be called:

controllers/todos.js
import todoModel from '../models/todos.js';
export default {
editTodo: (req, res) => {
const targetId = req.params.id;
const updatedTodo = todoModel.edit(targetId, {
title: req.body.title,
isCompleted: req.body.isCompleted,
});
if (!updatedTodo) {
return res.status(404).json({ error: 'Todo not found' });
}
return res.status(200).json(updatedTodo);
},
};

4. Models

A simple object containing the edit method can be called, primarily interacting with the database (this example does not use a database, using program execution internal variables as an example):

models/todos.js
let todos = [];
export default {
edit: (id, newTodo) => {
const targetIndex = todos.findIndex((todo) => todo.id === id);
const isTargetTodoExist = targetIndex !== -1;
if (!isTargetTodoExist) return null;
const updatedTodo = {
...todos[targetIndex],
title: newTodo.title ?? todos[targetIndex].title,
isCompleted: newTodo.isCompleted ?? todos[targetIndex].isCompleted,
};
const newTodos = [...todos.slice(0, targetIndex), updatedTodo, ...todos.slice(targetIndex + 1)];
todos = newTodos;
return updatedTodo;
},
};

Summary

Through the examples in this article, we refactored a centralized Express application into a modular design that follows the MVC architecture, clearly delineating the responsibilities between the data layer (Model), business logic layer (Controller), and routing layer (Route). This design not only enhances the readability and maintainability of the code but also lays a solid foundation for team collaboration and project expansion.

  • Single Responsibility: Each module focuses on completing its specific function, avoiding code mixing issues.
  • Easy to Test: Each layer module can be tested individually, reducing the complexity of testing.
  • Modular Development: Team members can independently develop Model, Controller, or Route, improving development efficiency.
  • Good Scalability: As the application grows, the MVC structure makes adding features and optimizations more intuitive and controllable.

Although this is a simplified example suitable for beginners, actual projects may also need to consider data validation, security, error handling, and multi-layer architecture (such as service layers) design.

Further Reading