Implementing the MVC Design Pattern in a Node.js Web Application
The Model-View-Controller pattern is one of the most enduring architectural ideas in software engineering. It was first described by Trygve Reenskaug at Xerox PARC in the late 1970s and has since become the structural backbone of countless web frameworks, from Ruby on Rails to ASP.NET MVC. Its appeal lies in a simple promise: if you separate the data, the presentation, and the logic that connects them, you get a codebase that is easier to reason about, easier to test, and easier to extend.
Node.js, with its unopinionated nature, does not impose MVC on you. That is both a strength and a source of confusion. Express, the most popular Node.js web framework, gives you a router and a middleware pipeline, but it says nothing about where your business logic should live or how your templates should relate to your data. This article shows you how to build that structure yourself, from the ground up, so that every design decision is visible and intentional.
What MVC actually means
Before writing any code, it is worth being precise about what each layer does, because the terms are often used loosely.
The Model is the single source of truth for application data. It encapsulates the business rules, validation logic, and persistence operations. A model knows how to create a user, validate an email address, or calculate an order total. It does not know whether the result will be rendered as HTML or sent as JSON.
The View is responsible for presentation. It receives data from the controller and transforms it into something a human can consume: an HTML page, a JSON response, an email body. A view never queries the database directly and never makes decisions about what data to fetch.
The Controller is the coordinator. It receives an incoming request, decides which model operations to invoke, gathers the results, and hands them to the appropriate view. Controllers should be thin. If a controller method grows beyond twenty or thirty lines, that is usually a sign that business logic has leaked out of the model.
The data flows in one direction: the request enters the controller, the controller calls the model, the model returns data, the controller passes that data to the view, and the view produces the response. This unidirectional flow is what makes MVC systems predictable.
Project structure
A well-organized directory layout is the first concrete expression of the MVC separation. Here is the structure we will build throughout this article:
project-root/
app.js
package.json
config/
database.js
models/
Book.js
controllers/
bookController.js
views/
layout.ejs
books/
index.ejs
show.ejs
create.ejs
edit.ejs
routes/
bookRoutes.js
public/
css/
style.css
Notice the routes/ directory. In many MVC frameworks, routing is handled inside the controller. In Express, however, routing is a distinct concern, and it is cleaner to keep it in its own layer. The route file maps URLs and HTTP methods to controller functions, acting as a thin dispatch table.
Setting up the project
Initialize the project and install the dependencies:
mkdir mvc-bookshelf && cd mvc-bookshelf
npm init -y
npm install express ejs mongoose
We are using Express as the HTTP framework, EJS as the templating engine for the view layer, and Mongoose as the ODM for MongoDB. Mongoose is particularly well-suited to the model layer because its schema and validation features map naturally onto the responsibilities of an MVC model.
Database configuration
Create the database connection module. Keeping this in a dedicated config file prevents connection logic from scattering across the codebase.
// config/database.js
const mongoose = require("mongoose");
async function connectDatabase(uri) {
try {
await mongoose.connect(uri);
console.log("Connected to MongoDB");
} catch (err) {
console.error("Database connection failed:", err.message);
process.exit(1);
}
}
module.exports = connectDatabase;
Application entry point
The app.js file wires everything together. It initializes the database connection, configures the templating engine, mounts the routes, and starts the server. It should contain no business logic whatsoever.
// app.js
const express = require("express");
const path = require("path");
const connectDatabase = require("./config/database");
const bookRoutes = require("./routes/bookRoutes");
const app = express();
const PORT = process.env.PORT || 3000;
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017/mvc_bookshelf";
// Template engine
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
// Middleware
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
// Routes
app.use("/books", bookRoutes);
app.get("/", (req, res) => {
res.redirect("/books");
});
// Start
connectDatabase(MONGO_URI).then(() => {
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
});
Building the Model
The model is where the real substance of the application lives. A Mongoose model gives us a schema (the shape of the data), validators (the business rules), and an interface to the database (the persistence layer). All three belong together.
// models/Book.js
const mongoose = require("mongoose");
const bookSchema = new mongoose.Schema(
{
title: {
type: String,
required: [true, "A book must have a title"],
trim: true,
maxlength: [200, "Title cannot exceed 200 characters"],
},
author: {
type: String,
required: [true, "A book must have an author"],
trim: true,
},
isbn: {
type: String,
unique: true,
match: [/^(?:\d{10}|\d{13})$/, "ISBN must be 10 or 13 digits"],
},
publishedYear: {
type: Number,
min: [1450, "Year must be after the invention of the printing press"],
max: [new Date().getFullYear(), "Year cannot be in the future"],
},
genre: {
type: String,
enum: {
values: ["fiction", "non-fiction", "science", "history", "biography", "other"],
message: "{VALUE} is not a supported genre",
},
default: "other",
},
summary: {
type: String,
maxlength: [2000, "Summary cannot exceed 2000 characters"],
},
},
{
timestamps: true,
}
);
// Instance method
bookSchema.methods.isClassic = function () {
return this.publishedYear < 1970;
};
// Static method
bookSchema.statics.findByGenre = function (genre) {
return this.find({ genre }).sort({ title: 1 });
};
module.exports = mongoose.model("Book", bookSchema);
There are several things to notice here. The validation messages are human-readable and specific; they will bubble up to the controller and eventually to the view, so they must make sense to an end user. The isClassic instance method encapsulates a business rule that would otherwise end up in a controller or a template. The findByGenre static method encapsulates a query that multiple controllers might need. All of this belongs in the model because it is about the data and the rules governing it.
Building the Controller
The controller is the thinnest layer. Each method corresponds to a single user action: listing all books, showing one book, rendering a form, creating a record, updating a record, or deleting one. The controller calls the model, handles errors, and delegates rendering to the view.
// controllers/bookController.js
const Book = require("../models/Book");
exports.index = async (req, res) => {
try {
const books = await Book.find().sort({ createdAt: -1 });
res.render("books/index", { books });
} catch (err) {
res.status(500).render("error", { message: "Could not load books" });
}
};
exports.show = async (req, res) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).render("error", { message: "Book not found" });
}
res.render("books/show", { book });
} catch (err) {
res.status(500).render("error", { message: "Could not load the book" });
}
};
exports.createForm = (req, res) => {
res.render("books/create", { errors: [], formData: {} });
};
exports.create = async (req, res) => {
try {
const book = new Book(req.body);
await book.save();
res.redirect(`/books/${book._id}`);
} catch (err) {
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(422).render("books/create", {
errors,
formData: req.body,
});
}
res.status(500).render("error", { message: "Could not create book" });
}
};
exports.editForm = async (req, res) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).render("error", { message: "Book not found" });
}
res.render("books/edit", { book, errors: [] });
} catch (err) {
res.status(500).render("error", { message: "Could not load the book" });
}
};
exports.update = async (req, res) => {
try {
const book = await Book.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
if (!book) {
return res.status(404).render("error", { message: "Book not found" });
}
res.redirect(`/books/${book._id}`);
} catch (err) {
if (err.name === "ValidationError") {
const errors = Object.values(err.errors).map((e) => e.message);
const book = { ...req.body, _id: req.params.id };
return res.status(422).render("books/edit", { book, errors });
}
res.status(500).render("error", { message: "Could not update book" });
}
};
exports.delete = async (req, res) => {
try {
const book = await Book.findByIdAndDelete(req.params.id);
if (!book) {
return res.status(404).render("error", { message: "Book not found" });
}
res.redirect("/books");
} catch (err) {
res.status(500).render("error", { message: "Could not delete book" });
}
};
Every method follows the same rhythm: call the model, check for errors, render or redirect. This regularity is deliberate. When a new developer opens this file, they should be able to predict the shape of the next method before reading it. If a method breaks the pattern, that is a signal that something is wrong.
Notice how validation errors are handled in the create and update methods. When the model rejects the data, the controller does not try to fix it. Instead, it extracts the error messages and passes them back to the form view along with the submitted data so the user can correct the mistakes. This round-trip is one of the most common MVC interaction patterns.
Building the Routes
The route file is a mapping table. It binds HTTP verbs and URL patterns to controller functions. Nothing more.
// routes/bookRoutes.js
const express = require("express");
const router = express.Router();
const bookController = require("../controllers/bookController");
router.get("/", bookController.index);
router.get("/new", bookController.createForm);
router.post("/", bookController.create);
router.get("/:id", bookController.show);
router.get("/:id/edit", bookController.editForm);
router.post("/:id/update", bookController.update);
router.post("/:id/delete", bookController.delete);
module.exports = router;
A note on HTTP methods: in a pure REST API you would use PUT or PATCH for updates and DELETE for deletions. HTML forms, however, only support GET and POST. In a server-rendered application like this one, it is common to use POST for all write operations. If you want strict REST semantics with HTML forms, you can use the method-override middleware.
Building the Views
The view layer converts raw data into HTML. EJS templates are plain HTML with embedded JavaScript expressions, which makes them easy to read for anyone familiar with both languages.
The layout
EJS does not have built-in layout support, but you can achieve it using partials or the ejs-mate package. For simplicity, we will use the include function to compose pages from a shared layout.
<!-- views/layout.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof title !== "undefined" ? title : "Bookshelf" %></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav>
<a href="/books">All Books</a>
<a href="/books/new">Add a Book</a>
</nav>
<%- content %>
</body>
</html>
The book list
<!-- views/books/index.ejs -->
<h1>All Books</h1>
<% if (books.length === 0) { %>
<p>No books yet. <a href="/books/new">Add one.</a></p>
<% } else { %>
<table>
<thead>
<tr>
<th>Title</th>
<th>Author</th>
<th>Year</th>
<th>Genre</th>
</tr>
</thead>
<tbody>
<% books.forEach(function(book) { %>
<tr>
<td><a href="/books/<%= book._id %>"><%= book.title %></a></td>
<td><%= book.author %></td>
<td><%= book.publishedYear || "N/A" %></td>
<td><%= book.genre %></td>
</tr>
<% }); %>
</tbody>
</table>
<% } %>
The single book view
<!-- views/books/show.ejs -->
<h1><%= book.title %></h1>
<p><strong>Author:</strong> <%= book.author %></p>
<p><strong>ISBN:</strong> <%= book.isbn || "Not provided" %></p>
<p><strong>Published:</strong> <%= book.publishedYear || "Unknown" %></p>
<p><strong>Genre:</strong> <%= book.genre %></p>
<% if (book.summary) { %>
<p><%= book.summary %></p>
<% } %>
<a href="/books/<%= book._id %>/edit">Edit</a>
<form action="/books/<%= book._id %>/delete" method="POST">
<button type="submit">Delete</button>
</form>
The creation form
<!-- views/books/create.ejs -->
<h1>Add a New Book</h1>
<% if (errors.length > 0) { %>
<ul>
<% errors.forEach(function(error) { %>
<li><%= error %></li>
<% }); %>
</ul>
<% } %>
<form action="/books" method="POST">
<label for="title">Title</label>
<input type="text" id="title" name="title"
value="<%= formData.title || '' %>" required>
<label for="author">Author</label>
<input type="text" id="author" name="author"
value="<%= formData.author || '' %>" required>
<label for="isbn">ISBN</label>
<input type="text" id="isbn" name="isbn"
value="<%= formData.isbn || '' %>">
<label for="publishedYear">Published Year</label>
<input type="number" id="publishedYear" name="publishedYear"
value="<%= formData.publishedYear || '' %>">
<label for="genre">Genre</label>
<select id="genre" name="genre">
<option value="fiction">Fiction</option>
<option value="non-fiction">Non-Fiction</option>
<option value="science">Science</option>
<option value="history">History</option>
<option value="biography">Biography</option>
<option value="other" selected>Other</option>
</select>
<label for="summary">Summary</label>
<textarea id="summary" name="summary" rows="5"><%= formData.summary || '' %></textarea>
<button type="submit">Save Book</button>
</form>
The edit form
<!-- views/books/edit.ejs -->
<h1>Edit: <%= book.title %></h1>
<% if (errors.length > 0) { %>
<ul>
<% errors.forEach(function(error) { %>
<li><%= error %></li>
<% }); %>
</ul>
<% } %>
<form action="/books/<%= book._id %>/update" method="POST">
<label for="title">Title</label>
<input type="text" id="title" name="title"
value="<%= book.title %>" required>
<label for="author">Author</label>
<input type="text" id="author" name="author"
value="<%= book.author %>" required>
<label for="isbn">ISBN</label>
<input type="text" id="isbn" name="isbn"
value="<%= book.isbn || '' %>">
<label for="publishedYear">Published Year</label>
<input type="number" id="publishedYear" name="publishedYear"
value="<%= book.publishedYear || '' %>">
<label for="genre">Genre</label>
<select id="genre" name="genre">
<option value="fiction" <%= book.genre === 'fiction' ? 'selected' : '' %>>Fiction</option>
<option value="non-fiction" <%= book.genre === 'non-fiction' ? 'selected' : '' %>>Non-Fiction</option>
<option value="science" <%= book.genre === 'science' ? 'selected' : '' %>>Science</option>
<option value="history" <%= book.genre === 'history' ? 'selected' : '' %>>History</option>
<option value="biography" <%= book.genre === 'biography' ? 'selected' : '' %>>Biography</option>
<option value="other" <%= book.genre === 'other' ? 'selected' : '' %>>Other</option>
</select>
<label for="summary">Summary</label>
<textarea id="summary" name="summary" rows="5"><%= book.summary || '' %></textarea>
<button type="submit">Update Book</button>
</form>
How the layers interact at runtime
To see the pattern in motion, let us trace a single request through the entire stack. A user submits the "Add a New Book" form with a missing author field.
- The browser sends
POST /bookswith the form data in the request body. - Express matches the route and calls
bookController.create. - The controller instantiates a new
Bookmodel withreq.bodyand callssave(). - Mongoose runs the schema validators. The
authorfield is required but missing, so Mongoose throws aValidationError. - The controller catches the error, extracts the human-readable messages, and re-renders the
books/createview with the errors and the original form data. - The view iterates over the error messages and displays them above the form. The form fields are pre-filled with the previously submitted values so the user does not have to retype everything.
At no point does the view query the database. At no point does the model produce HTML. At no point does the controller contain validation rules. Each layer does exactly one thing.
Testing each layer in isolation
One of the most important benefits of MVC is testability. Because each layer has a defined interface, you can test it without involving the others.
Testing the model
Model tests verify business rules and data integrity. They use the model directly, without HTTP or templates.
// tests/models/Book.test.js
const mongoose = require("mongoose");
const Book = require("../../models/Book");
beforeAll(async () => {
await mongoose.connect("mongodb://localhost:27017/mvc_bookshelf_test");
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
afterEach(async () => {
await Book.deleteMany({});
});
describe("Book model", () => {
test("rejects a book without a title", async () => {
const book = new Book({ author: "George Orwell" });
await expect(book.save()).rejects.toThrow("A book must have a title");
});
test("rejects an invalid ISBN", async () => {
const book = new Book({
title: "1984",
author: "George Orwell",
isbn: "ABC",
});
await expect(book.save()).rejects.toThrow("ISBN must be 10 or 13 digits");
});
test("identifies a classic correctly", () => {
const book = new Book({
title: "Moby Dick",
author: "Herman Melville",
publishedYear: 1851,
});
expect(book.isClassic()).toBe(true);
});
test("does not classify a modern book as classic", () => {
const book = new Book({
title: "Project Hail Mary",
author: "Andy Weir",
publishedYear: 2021,
});
expect(book.isClassic()).toBe(false);
});
});
Testing the controller
Controller tests verify that the right model methods are called and the right view is rendered. You can use supertest to make HTTP requests against the Express app without starting a real server.
// tests/controllers/bookController.test.js
const request = require("supertest");
const mongoose = require("mongoose");
const express = require("express");
const path = require("path");
const Book = require("../../models/Book");
const bookRoutes = require("../../routes/bookRoutes");
let app;
beforeAll(async () => {
await mongoose.connect("mongodb://localhost:27017/mvc_bookshelf_test");
app = express();
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "../../views"));
app.use(express.urlencoded({ extended: true }));
app.use("/books", bookRoutes);
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
afterEach(async () => {
await Book.deleteMany({});
});
describe("GET /books", () => {
test("renders the index view with all books", async () => {
await Book.create({ title: "Dune", author: "Frank Herbert" });
const res = await request(app).get("/books");
expect(res.status).toBe(200);
expect(res.text).toContain("Dune");
});
});
describe("POST /books", () => {
test("creates a book and redirects to its page", async () => {
const res = await request(app)
.post("/books")
.send("title=Neuromancer&author=William+Gibson&genre=fiction");
expect(res.status).toBe(302);
const book = await Book.findOne({ title: "Neuromancer" });
expect(book).not.toBeNull();
expect(book.author).toBe("William Gibson");
});
test("re-renders the form on validation failure", async () => {
const res = await request(app)
.post("/books")
.send("title=&author=William+Gibson");
expect(res.status).toBe(422);
expect(res.text).toContain("A book must have a title");
});
});
Common mistakes and how to avoid them
The most frequent MVC violation is the fat controller. It happens when developers put business logic, data transformations, or even database queries directly in the controller methods. The fix is to move that logic into the model as instance methods, static methods, or virtual properties. If the logic does not naturally belong to a single model, consider creating a service module that orchestrates multiple models.
Another common mistake is logic in the view. Templates should contain only presentation logic: loops, conditionals for showing or hiding elements, and formatting. If you find yourself writing complex calculations or data lookups inside an EJS template, that code belongs in the controller or the model. A useful rule of thumb is that a template should never contain more than three lines of JavaScript in a single <% %> block.
A subtler issue is coupling between the model and the view. If your Mongoose schema has a method called toHTML() or renderCard(), that is a layer violation. The model should expose data and behavior; it should never know how it will be displayed. If you need to transform data for presentation, do it in the controller or use a view helper function.
Scaling beyond the basics
As an application grows, the basic MVC structure can be extended in several ways without breaking the core separation.
Service layer. When business logic involves multiple models or external APIs, extract it into service modules. A services/orderService.js might coordinate between the Order, Inventory, and Payment models. Controllers call services; services call models. The data flow remains unidirectional.
Middleware for cross-cutting concerns. Authentication, authorization, logging, and rate limiting are not the responsibility of any single controller. Express middleware handles these concerns before the request reaches the controller, keeping the controllers focused on their core task.
View helpers. Formatting dates, truncating strings, and generating CSS class names based on data values are presentation concerns that clutter templates. Extract them into helper functions and pass them to your templates through res.locals or a shared middleware.
API and HTML from the same controllers. If your application serves both a web interface and a JSON API, you can use content negotiation in the controller to decide which view to render. Check the Accept header or a query parameter and respond with either an EJS template or a res.json() call. The model and the business logic remain identical for both.
Conclusion
MVC is not a framework feature. It is a discipline. Node.js and Express give you the freedom to organize your code any way you like, and MVC is a particularly effective way to use that freedom. The model owns the data and the rules. The view owns the presentation. The controller owns the coordination. When each layer stays within its boundaries, the result is a codebase that is legible to newcomers, resilient to change, and straightforward to test.
The bookshelf application we built in this article is small, but the principles behind it scale. Whether you are building a personal project or a production system serving thousands of users, the same separation of concerns will serve you well. The key is to be strict about it early, because layer violations are easy to introduce and expensive to remove.