Express MVC Pattern
Introduction
The Model-View-Controller (MVC) pattern is a software architectural pattern that separates an application into three main logical components: the Model, the View, and the Controller. Each of these components handles specific aspects of an application's development and maintenance.
When building Express.js applications, implementing the MVC pattern helps organize your code better, making it more maintainable, testable, and scalable. This approach is especially helpful as your application grows in size and complexity.
In this tutorial, we'll explore how to implement the MVC pattern in an Express.js application, complete with practical examples and best practices.
Understanding MVC Components
Before diving into implementation, let's understand what each component does:
-
Model: Manages data, logic, and rules of the application. It communicates with the database and handles data validation.
-
View: Handles the UI parts of the application. In Express, this typically involves rendering templates (like EJS, Pug, or Handlebars) or serving JSON data for front-end frameworks.
-
Controller: Acts as an intermediary between Model and View. It processes incoming requests, interacts with the Model to get data, and passes that data to the View.
Setting Up an Express MVC Project
Let's create a simple Express application using the MVC pattern. We'll build a basic book management system.
Project Structure
First, let's establish our folder structure:
book-management-app/
├── controllers/
│ └── bookController.js
├── models/
│ └── Book.js
├── views/
│ ├── books/
│ │ ├── index.ejs
│ │ ├── show.ejs
│ │ ├── new.ejs
│ │ └── edit.ejs
├── routes/
│ └── bookRoutes.js
├── app.js
└── package.json
Setting Up the Project
Initialize the project:
mkdir book-management-app
cd book-management-app
npm init -y
npm install express ejs mongoose body-parser
Creating the Main Application File
Let's create our app.js
:
const express = require('express');
const mongoose = require('mongoose');
const bodyParser = require('body-parser');
const path = require('path');
// Initialize express app
const app = express();
// Connect to MongoDB
mongoose.connect('mongodb://localhost/bookmanagement', {
useNewUrlParser: true,
useUnifiedTopology: true
})
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('Could not connect to MongoDB', err));
// Middleware
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Routes
const bookRoutes = require('./routes/bookRoutes');
app.use('/books', bookRoutes);
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Implementing the Model
Let's create our Book model in models/Book.js
:
const mongoose = require('mongoose');
const bookSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
author: {
type: String,
required: true,
trim: true
},
publishYear: {
type: Number,
required: true
},
description: {
type: String,
trim: true
},
createdAt: {
type: Date,
default: Date.now
}
});
const Book = mongoose.model('Book', bookSchema);
module.exports = Book;
The model defines the structure of our data and interacts with the database. In this case, we're using Mongoose to create a Book schema that includes fields like title, author, and publish year.
Creating Controllers
Next, let's create our controller in controllers/bookController.js
:
const Book = require('../models/Book');
// Display list of all books
exports.book_list = async (req, res) => {
try {
const books = await Book.find({}).sort({ createdAt: -1 });
res.render('books/index', {
title: 'All Books',
books: books
});
} catch (err) {
res.status(500).send('Error retrieving books: ' + err.message);
}
};
// Display detail page for a specific book
exports.book_detail = async (req, res) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).send('Book not found');
}
res.render('books/show', {
title: book.title,
book: book
});
} catch (err) {
res.status(500).send('Error retrieving book: ' + err.message);
}
};
// Display book create form
exports.book_create_get = (req, res) => {
res.render('books/new', { title: 'Create New Book' });
};
// Handle book create on POST
exports.book_create_post = async (req, res) => {
try {
const book = new Book({
title: req.body.title,
author: req.body.author,
publishYear: req.body.publishYear,
description: req.body.description
});
await book.save();
res.redirect('/books');
} catch (err) {
res.status(400).render('books/new', {
title: 'Create New Book',
error: err.message
});
}
};
// Display book delete form
exports.book_delete_get = async (req, res) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).send('Book not found');
}
res.render('books/delete', {
title: 'Delete Book',
book: book
});
} catch (err) {
res.status(500).send('Error retrieving book: ' + err.message);
}
};
// Handle book delete on POST
exports.book_delete_post = async (req, res) => {
try {
await Book.findByIdAndRemove(req.params.id);
res.redirect('/books');
} catch (err) {
res.status(500).send('Error deleting book: ' + err.message);
}
};
// Display book update form
exports.book_update_get = async (req, res) => {
try {
const book = await Book.findById(req.params.id);
if (!book) {
return res.status(404).send('Book not found');
}
res.render('books/edit', {
title: 'Update Book',
book: book
});
} catch (err) {
res.status(500).send('Error retrieving book: ' + err.message);
}
};
// Handle book update on POST
exports.book_update_post = async (req, res) => {
try {
await Book.findByIdAndUpdate(req.params.id, {
title: req.body.title,
author: req.body.author,
publishYear: req.body.publishYear,
description: req.body.description
});
res.redirect(`/books/${req.params.id}`);
} catch (err) {
res.status(400).render('books/edit', {
title: 'Update Book',
book: req.body,
error: err.message
});
}
};
The controller contains methods that handle specific actions like listing all books, showing details of a specific book, creating new books, etc. Each method interacts with the Model to fetch or update data and then passes that data to the appropriate View.
Setting Up Routes
Now let's create our routes in routes/bookRoutes.js
:
const express = require('express');
const router = express.Router();
// Require the controller
const book_controller = require('../controllers/bookController');
// Book routes
router.get('/', book_controller.book_list);
router.get('/new', book_controller.book_create_get);
router.post('/new', book_controller.book_create_post);
router.get('/:id', book_controller.book_detail);
router.get('/:id/edit', book_controller.book_update_get);
router.post('/:id/edit', book_controller.book_update_post);
router.get('/:id/delete', book_controller.book_delete_get);
router.post('/:id/delete', book_controller.book_delete_post);
module.exports = router;
The routes map URLs to controller methods, defining the API of our application.
Creating Views
Finally, let's create a sample view in views/books/index.ejs
:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.book-list { list-style-type: none; padding: 0; }
.book-item { border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 4px; }
.book-title { font-size: 18px; color: #0066cc; }
.book-author { font-size: 14px; color: #666; }
.book-year { font-size: 12px; color: #999; }
.add-btn {
display: inline-block;
background: #0066cc;
color: white;
padding: 10px 15px;
text-decoration: none;
border-radius: 4px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<h1><%= title %></h1>
<a href="/books/new" class="add-btn">Add New Book</a>
<% if (books && books.length > 0) { %>
<ul class="book-list">
<% books.forEach(function(book) { %>
<li class="book-item">
<div class="book-title">
<a href="/books/<%= book._id %>"><%= book.title %></a>
</div>
<div class="book-author">by <%= book.author %></div>
<div class="book-year">Published in <%= book.publishYear %></div>
</li>
<% }); %>
</ul>
<% } else { %>
<p>No books found.</p>
<% } %>
</body>
</html>
And let's create a view to display a specific book's details in views/books/show.ejs
:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.book-detail { border: 1px solid #ddd; padding: 20px; border-radius: 4px; }
.book-title { font-size: 24px; color: #0066cc; margin-bottom: 10px; }
.book-author { font-size: 18px; color: #666; margin-bottom: 10px; }
.book-year { font-size: 16px; color: #999; margin-bottom: 20px; }
.book-description { line-height: 1.6; }
.nav-links { margin-top: 20px; }
.nav-links a {
display: inline-block;
padding: 8px 12px;
margin-right: 10px;
text-decoration: none;
color: white;
border-radius: 4px;
}
.back-btn { background: #666; }
.edit-btn { background: #0066cc; }
.delete-btn { background: #cc0000; }
</style>
</head>
<body>
<div class="book-detail">
<div class="book-title"><%= book.title %></div>
<div class="book-author">by <%= book.author %></div>
<div class="book-year">Published in <%= book.publishYear %></div>
<% if (book.description) { %>
<div class="book-description">
<%= book.description %>
</div>
<% } %>
</div>
<div class="nav-links">
<a href="/books" class="back-btn">Back to Books</a>
<a href="/books/<%= book._id %>/edit" class="edit-btn">Edit</a>
<a href="/books/<%= book._id %>/delete" class="delete-btn">Delete</a>
</div>
</body>
</html>
Views are responsible for presenting data to the user. In Express, views are typically template files that get rendered with data passed from controllers.
How MVC Works Together in Express
Let's trace a typical request flow through our MVC architecture:
- A user navigates to
/books
in their browser - The request is routed to the
book_list
method in thebookController
by our routes file - The controller uses the Book model to fetch all books from the database
- The controller passes the books data to the index view
- The view renders the HTML with the book data
- The HTML response is sent back to the user's browser
This separation of concerns makes our code more maintainable and testable.
Best Practices for Express MVC
-
Keep controllers focused: Controllers should handle HTTP requests and responses, delegating business logic to models or separate service modules.
-
Fat models, skinny controllers: Put as much business logic as possible in your models, keeping controllers lightweight.
-
Use middleware: Express middleware is great for cross-cutting concerns like authentication, logging, and error handling.
-
Error handling: Implement consistent error handling across your application.
-
Input validation: Validate all user inputs, preferably in the model or using middleware.
Real-World Example: RESTful API with MVC
Let's modify our book controller to support both HTML views and JSON responses for a RESTful API:
exports.book_list = async (req, res) => {
try {
const books = await Book.find({}).sort({ createdAt: -1 });
// Check the requested format
const format = req.query.format || 'html';
if (format === 'json') {
return res.json(books);
}
res.render('books/index', {
title: 'All Books',
books: books
});
} catch (err) {
if (req.query.format === 'json') {
return res.status(500).json({ error: err.message });
}
res.status(500).send('Error retrieving books: ' + err.message);
}
};
With this approach, clients can request data in different formats:
- Browser users visiting
/books
will see the HTML page - API clients requesting
/books?format=json
will receive JSON data
Summary
In this tutorial, we've explored how to implement the MVC pattern in Express.js applications:
- Models handle data access and business logic
- Views present data to users
- Controllers coordinate between models and views
- Routes direct requests to the appropriate controller methods
The MVC pattern helps you organize your code for better maintainability, testability, and scalability. As your application grows, this organization will help keep your codebase manageable.
Further Resources and Exercises
Resources
- Express.js Documentation
- Mongoose Documentation
- EJS Documentation
- "Node.js Design Patterns" by Mario Casciaro
Exercises
-
Complete the application: Create the remaining views (
new.ejs
,edit.ejs
, anddelete.ejs
) to complete the book management application. -
Add validation: Enhance the Book model with more sophisticated validation (e.g., ensure publish year is not in the future).
-
Add user authentication: Implement a User model and authentication to restrict book creation/editing to logged-in users.
-
API versioning: Modify your routes to support API versioning (e.g.,
/api/v1/books
). -
Implement pagination: Update the book list controller to support paginated results.
By mastering the MVC pattern in Express, you'll build more maintainable applications and be better prepared for working on larger teams and projects.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)