Topic 1: The Model-View-Controller Pattern
📖 7 min read · 🎯 beginner · 🧭 Prerequisites: introduction-and-foundation
Why this matters
Up until now, you've probably written Node.js code where everything lives together — your database queries, your HTML, your route logic, all jumbled in one file. It works at first, but then you try to change one thing and three other things break. Sound familiar? That's what developers call "spaghetti code." The Model-View-Controller pattern — MVC — is the industry's answer to this. It cuts your Express app into three clean, independent layers, so each piece has one job and changing one doesn't accidentally wreck the others.
What You'll Learn
- Understand the three components of MVC — Model, View, and Controller — and what each one owns
- Scaffold an MVC directory structure for a Node.js/Express application
- Implement a working Book model, controller, and EJS view wired together through Express routes
- Run the complete application and verify data flows correctly from model to browser
The Analogy
Think of a restaurant. The kitchen (Model) holds all the food and knows the recipes — it has no idea who is sitting at the tables. The waitstaff (Controller) takes your order, walks it back to the kitchen, and carries the finished plate out to you. The dining room (View) is what you actually see and interact with: candles, menus, plated food — pure presentation. If the chef changes a recipe, the dining room doesn't need a renovation. If the décor changes, the kitchen keeps cooking the same way. Each part has one job, and they talk through well-defined handoffs.
Chapter 1: Introduction to the MVC Pattern
MVC (Model-View-Controller) is a design pattern that divides an application into three interconnected components. This separation manages the complexity of large applications by promoting modularity and separation of concerns.
| Component | Responsibility |
|---|---|
| Model | Represents data and business logic |
| View | Represents the presentation layer — what the user sees |
| Controller | Handles user input, coordinates between Model and View |
Key concepts to lock in before going further:
- Model — the single source of truth for data and the rules that govern it. It knows nothing about HTTP or HTML.
- View — a template that renders data handed to it. It knows nothing about where the data came from.
- Controller — the traffic cop. It receives a request, asks the Model for data, and passes that data to the View to render.
flowchart LR
User -->|HTTP Request| Controller
Controller -->|reads/writes| Model
Model -->|returns data| Controller
Controller -->|passes data| View
View -->|rendered HTML| User
Chapter 2: Setting Up the MVC Structure
Step 1: Create a New Node.js Project
mkdir mvc-demo
cd mvc-demo
npm init -y
npm install express
Step 2: Set Up the Project Structure
mvc-demo/
├── controllers/
│ └── bookController.js
├── models/
│ └── bookModel.js
├── routes/
│ └── bookRoutes.js
├── views/
│ └── books.ejs
├── public/
│ └── styles.css
├── app.js
├── package.json
└── package-lock.json
Each folder maps to one MVC concern. Routes sit alongside controllers because routing is how Express hands a request to the right controller function — it is glue, not a fourth layer.
Chapter 3: Creating the Model
The Model owns the data and every operation on it. For this demo the class uses an in-memory array, but the same interface would work with a database later — the controller would never need to change.
models/bookModel.js
let books = [
{ id: 1, title: '1984', author: 'George Orwell' },
{ id: 2, title: 'Brave New World', author: 'Aldous Huxley' },
];
function getAllBooks() {
return books;
}
function getBookById(id) {
return books.find(book => book.id === id);
}
function addBook(book) {
book.id = books.length + 1;
books.push(book);
}
module.exports = {
getAllBooks,
getBookById,
addBook
};
Notice that bookModel.js has zero knowledge of req, res, or any HTTP concept. It is pure data logic.
Chapter 4: Creating the Controller
The Controller imports the Model, retrieves or mutates data, then tells Express which View to render — or redirects as needed.
controllers/bookController.js
const bookModel = require('../models/bookModel');
function getBooks(req, res) {
const books = bookModel.getAllBooks();
res.render('books', { books });
}
function getBook(req, res) {
const book = bookModel.getBookById(parseInt(req.params.id));
if (book) {
res.render('book', { book });
} else {
res.status(404).send('Book not found');
}
}
function createBook(req, res) {
const newBook = {
title: req.body.title,
author: req.body.author
};
bookModel.addBook(newBook);
res.redirect('/books');
}
module.exports = {
getBooks,
getBook,
createBook
};
Three functions, three responsibilities:
getBooks— fetches all books and renders the list viewgetBook— fetches one book by URL param; returns 404 if missingcreateBook— reads POST body, adds the book via the model, then redirects back to the list
Chapter 5: Creating the View
The View is a template — it receives data and emits HTML. The class chose EJS (Embedded JavaScript), which lets you drop <% %> tags into otherwise normal HTML.
views/books.ejs
<!DOCTYPE html>
<html>
<head>
<title>Books</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<h1>Books</h1>
<ul>
<% books.forEach(book => { %>
<li><%= book.title %> by <%= book.author %></li>
<% }) %>
</ul>
<form action="/books" method="POST">
<input type="text" name="title" placeholder="Title" required>
<input type="text" name="author" placeholder="Author" required>
<button type="submit">Add Book</button>
</form>
</body>
</html>
EJS tag cheat sheet:
<% %>— executes JavaScript (no output)<%= %>— evaluates and outputs the value (HTML-escaped)
The view never calls require. It only works with variables passed in by the controller.
Chapter 6: Setting Up Routes
Routes map HTTP verb + URL path to a specific controller function. Keeping routes in their own file means app.js stays uncluttered as the application grows.
routes/bookRoutes.js
const express = require('express');
const router = express.Router();
const bookController = require('../controllers/bookController');
router.get('/books', bookController.getBooks);
router.get('/books/:id', bookController.getBook);
router.post('/books', bookController.createBook);
module.exports = router;
The three routes handle:
GET /books— list all booksGET /books/:id— show one book by IDPOST /books— create a new book from a form submission
Chapter 7: Setting Up the Server
app.js is the entry point. It wires middleware, the view engine, and all route modules together.
app.js
const express = require('express');
const path = require('path');
const bookRoutes = require('./routes/bookRoutes');
const app = express();
const PORT = 3000;
// Middleware
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.urlencoded({ extended: true }));
// View engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Routes
app.use('/', bookRoutes);
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Key lines explained:
express.static— serves everything in/public(CSS, images, JS) at the root URLexpress.urlencoded— parses form POST bodies soreq.bodyis populatedapp.set('view engine', 'ejs')— tells Express to use EJS whenres.render()is called
CSS: public/styles.css
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 20px;
}
h1 {
color: #333;
}
ul {
list-style-type: none;
padding: 0;
}
li {
background: #fff;
margin: 5px 0;
padding: 10px;
border: 1px solid #ddd;
}
form {
margin-top: 20px;
}
input, button {
padding: 10px;
margin: 5px 0;
}
Chapter 8: Running the Application
node app.js
Navigate to http://localhost:3000/books in a browser. You should see the two seed books rendered as a list with a form below. Submit the form with a new title and author — the page will redirect back to /books with the new entry appended to the list.
🧪 Try It Yourself
Task: Add a deleteBook function to the MVC stack — model, controller, and route.
- In
models/bookModel.js, add:
function deleteBook(id) {
books = books.filter(book => book.id !== id);
}
Export it alongside the others.
- In
controllers/bookController.js, add:
function removeBook(req, res) {
bookModel.deleteBook(parseInt(req.params.id));
res.redirect('/books');
}
Export it.
- In
routes/bookRoutes.js, add:
router.post('/books/:id/delete', bookController.removeBook);
- In
views/books.ejs, add a delete form inside theforEachloop:
<form action="/books/<%= book.id %>/delete" method="POST" style="display:inline;">
<button type="submit">Delete</button>
</form>
Success criterion: Clicking Delete next to a book removes it from the list immediately on redirect. The remaining books retain their display order.
🔍 Checkpoint Quiz
Q1. What is the primary reason for using the MVC pattern in a web application?
A) It makes the application run faster
B) It separates data, presentation, and control logic so each can change independently
C) It removes the need for a database
D) It lets you avoid writing CSS
Q2. Given this controller snippet, what happens when a request hits GET /books/99 and no book with id 99 exists?
function getBook(req, res) {
const book = bookModel.getBookById(parseInt(req.params.id));
if (book) {
res.render('book', { book });
} else {
res.status(404).send('Book not found');
}
}
A) The server crashes with an unhandled exception
B) An empty book object is rendered
C) The response sends a 404 status with the text "Book not found"
D) The request is redirected to /books
Q3. A teammate adds database queries directly inside views/books.ejs. Which MVC principle does this violate, and which file should those queries live in instead?
Q4. Which Express middleware line is required for req.body.title to be populated when a form is submitted via POST?
A) app.use(express.static('public'))
B) app.use(express.json())
C) app.use(express.urlencoded({ extended: true }))
D) app.set('view engine', 'ejs')
A1. B — MVC enforces separation of concerns: the Model, View, and Controller each evolve without forcing changes in the others.
A2. C — The else branch runs, setting the HTTP status to 404 and sending the plain-text string "Book not found".
A3. This violates the separation of concerns principle — specifically the rule that the View only renders data; it should never fetch or mutate it. The queries belong in the Model (models/bookModel.js), called from the Controller which then passes results to the view.
A4. C — express.urlencoded parses application/x-www-form-urlencoded bodies (the default encoding for HTML forms) and attaches the parsed key-value pairs to req.body.
🪞 Recap
- MVC splits an application into Model (data/logic), View (templates/HTML), and Controller (request handling/coordination).
- The Model has zero knowledge of HTTP; the View has zero knowledge of data sources — only the Controller bridges them.
- Express routes map HTTP verb + path to a specific controller function, keeping
app.jslean. - EJS templates use
<%= %>to output values and<% %>to run logic without output. express.urlencoded({ extended: true })is required to read form POST data fromreq.body.
📚 Further Reading
- Express.js Routing docs — the source of truth on how
Routerand route methods work - EJS documentation — full EJS tag reference and configuration options
- MDN: MVC — concise definition with browser-side context
- ⬅️ Previous: Introduction and Foundation
- ➡️ Next: Configuring Express