W03 Learning Activity: The Model-View-Controller Pattern
Overview
In this learning activity, you will learn about the Model-View-Controller (MVC) architectural pattern, a fundamental concept in web development that helps organize code for better maintainability and scalability.
You will refactor your server.js file to follow the MVC pattern.
Preparation Material
As your Express applications grow beyond simple examples, your server.js file can become hundreds of lines long and impossible to navigate. Finding the code that handles user data means scrolling through route definitions, template logic, and database queries all mixed together. Adding a new feature requires changes scattered across multiple parts of the same file. Debugging becomes a nightmare because everything is interconnected in confusing ways.
These problems are not unique to you or your applications. Every developer faces them as projects grow from simple prototypes to real applications. The solution is not to write better code in the same structure, but to change how you organize your code. This is where architectural patterns come in, and the most important one for web applications is Model-View-Controller (MVC).
The Problem with "Everything in One File"
Imagine you are building a simple e-commerce site that started with just a few routes: a home page, a products list, and a contact form. Initially, having everything in server.js seemed fine. But as the site grows, you add user accounts, shopping carts, order processing, admin panels, and inventory management. Suddenly, your server.js file contains:
- Route handlers for dozens of different pages
- Functions that validate user input and process orders
- Database queries scattered throughout different routes
- Business logic mixed with presentation logic
- Error handling duplicated across multiple places
This creates what developers call "spaghetti code" – everything is tangled together, making it nearly impossible to understand, modify, or maintain. When you need to change how products are displayed, you might accidentally break the order processing logic. When you fix a bug in user authentication, you might introduce problems in the shopping cart.
Professional developers solve this problem by using architectural patterns that provide proven ways to organize code. These patterns are not arbitrary rules, but solutions that have evolved over decades to address the real problems that occur in growing applications.
What is MVC Architecture?
Model-View-Controller (MVC) is a software design pattern that organizes code by separating different types of responsibilities into three distinct components. Instead of having all your code mixed together, MVC creates clear boundaries between different concerns:
- Model: Manages data and business logic.
- View: Handles user interface and presentation.
- Controller: Coordinates between Model and View.
Think of MVC like a well-organized restaurant: The kitchen (Model) focuses entirely on preparing food according to recipes and managing ingredients. The dining room staff and menu design (View) focus on presenting food attractively and creating a pleasant customer experience. The servers (Controller) coordinate between the kitchen and customers, taking orders, delivering food, and ensuring everything flows smoothly. Each area has clear responsibilities, and no one steps on anyone else's toes.
Why MVC Solves Real Problems
MVC architecture addresses the specific problems that make applications difficult to maintain and expand:
Separation of Concerns
By dividing responsibilities among Model, View, and Controller, different types of changes affect different parts of your codebase. If you need to change how product data is validated, you only need to touch the Model. If you want to redesign the product display page, you only need to modify the View. If you need to add a new route or change the flow between pages, you can focus on the Controller. This separation means you can make changes confidently without worrying about breaking unrelated functionality.
Team Collaboration
In professional development, different team members often specialize in different areas. Designers work on Views, database specialists work on Models, and application architects work on Controllers. MVC makes this collaboration possible because each person can work on their area without conflicting with others. Even as a solo developer, this separation helps you focus on one type of problem at a time.
Code Reusability
Components in an MVC application can be reused across different parts of the application. The same product Model that handles data for the product listing page can be used for the product detail page, the shopping cart, and the admin inventory system. The same authentication Controller logic can protect multiple different routes. This eliminates duplication and ensures consistency.
Easier Testing and Debugging
With clear separation, you can test each component independently. You can test Model functions with sample data to ensure they handle edge cases correctly. You can test Controller logic to verify it routes requests properly. You can test Views to confirm they display data correctly. When bugs occur, the organized structure makes it much easier to identify which component is responsible.
The MVC Components in Detail
Model: The Data and Logic Layer
The Model is responsible for managing your application's data and implementing business logic. Business logic refers to the rules and operations that define how your application works – not just storing and retrieving data, but applying the rules that make your application unique.
For an e-commerce application, Model responsibilities might include:
- Validating that email addresses are properly formatted
- Calculating shipping costs based on location and order size
- Determining if a user qualifies for a discount
- Checking if items are in stock before allowing purchases
- Processing payment transactions and updating inventory
The Model interacts with databases, external APIs, files, or any other data sources, but it never knows or cares about how that data will be displayed to users. This separation means you could completely change your website's design without touching any Model code, or you could add a mobile app that uses the same Models with a completely different interface.
View: The Presentation Layer
The View is responsible for presenting data to users in an understandable and visually appealing way. In web applications, Views are typically HTML templates that get populated with data, but the concept extends to any user interface component.
You have already been working with Views without realizing it. Every EJS template file you create is a View component. These templates focus purely on presentation: how to display a list of products, how to format a user profile page, how to show error messages, or how to render a navigation menu.
Views should contain minimal logic – just enough to format and display data, but no business rules or data processing. For example, a View might format a date for display or loop through a list of items, but it should not calculate prices or make decisions about what data to show.
Controller: The Coordination Layer
The Controller acts as the coordinator between Models and Views, handling the flow of requests and responses. Controllers receive user input (like clicking a link, submitting a form, or visiting a URL), determine what needs to happen, coordinate with Models to get or process data, and then coordinate with Views to present the results.
In Express applications, Controllers are implemented as route handlers – the functions that respond to specific HTTP requests. You have been writing Controller code every time you create a route that processes requests and decides what to send back to the user.
Controllers should be relatively thin, containing mostly coordination logic rather than business logic. They ask Models to do the heavy lifting of data processing and business rules, then pass the results to Views for presentation.
How MVC Components Work Together
Understanding the flow of data and control in an MVC application helps clarify how the components interact. Let's trace through a realistic example: a user viewing a product detail page.
-
User Request: A user clicks a link to view product details, sending a request to
/products/laptop-123. -
Controller Receives Request: The Express route handler (Controller) for
/products/:idreceives the request and extracts the product ID from the URL parameters. -
Controller Asks Model for Data: The Controller calls a Model function like
Product.getById('laptop-123')to retrieve the product information. - Model Processes Request: The Product Model queries the database, validates that the product exists, and might also check if it is in stock or if the current user gets special pricing.
- Model Returns Data: The Model returns the product data (or an error if the product does not exist) to the Controller.
- Controller Chooses View: Based on the Model response, the Controller decides which View to render – either the product detail template or an error page.
-
Controller Passes Data to View: The Controller calls something like
res.render('product-detail', { product: productData }). - View Renders Response: The EJS template (View) generates HTML using the product data and sends it back to the user's browser.
Notice how each component has a clear, focused responsibility. The Model never knows whether the product data will be displayed on a web page, in a mobile app, or in an API response. The View never knows where the product data came from or what business rules were applied. The Controller just coordinates between them, keeping the overall flow simple and predictable.
Recognizing MVC Patterns in Your Existing Code
You have already been implementing parts of MVC architecture without realizing it. Your EJS templates are Views, your route handlers are Controllers, and the functions you have written to get data from the database are the Model.
From now on, you will start to be more intentional about organizing your code into these distinct components.
Models
You have also already created model functions in your models/ folder. These functions query from the database and return data, representing the data access layer of MVC.
Beginning this week, you will start to create more complicated business logic in your model functions.
Views
Your views/ folder already represents the View layer of MVC. Every EJS template you have created focuses on presentation – taking data and displaying it as HTML. This separation is one of the key benefits of using a templating engine instead of mixing HTML strings directly in your route handlers.
Controllers (hidden in routes)
Every route handler function you have written is essentially a Controller. When you write app.get('/projects', (req, res) => { ... }), the callback function is Controller logic that receives requests, processes them, and coordinates responses. Currently, these Controllers are mixed together with other code in your main server file, but they represent the coordination layer of MVC.
Activity Instructions
As noted, your Views and Models are already well-defined in your project structure in the views/ and models/ folders.
In this activity, you will refactor your current code to follow the MVC architecture more explicitly by pulling out the controller logic from the server.js file into separate controller modules.
Then, you will be in a position to add more advanced features and business logic to your application while keeping your code organized and maintainable.
Understand the plan
In the next few steps, you will move all of the router and controller logic out of server.js into other files. Before moving any code, it is important to understand what you will be moving.
The following snippet contains one of the route handlers from your server.js file:
app.get('/organizations', async (req, res) => {
const organizations = await getAllOrganizations();
const title = 'Our Partner Organizations';
res.render('organizations', { title, organizations });
});
In this code, the callback function is the controller logic. You will give this function a name (organizationsPage) and move it to a controller file src/controllers/organizations.js. For example, this function will look something like the following:
const organizationsPage = async (req, res) => {
const organizations = await getAllOrganizations();
const title = 'Our Partner Organizations';
res.render('organizations', { title, organizations });
});
Then, the routing logic that calls this controller function (app.get('/organizations', async (req, res) => { ... })) will be updated to call this function name ( app.get('/organizations', organizationsPage); ) and placed in a separate routing file: src/controllers/routes.js. It will look something like this:
import { organizationsPage } from './organizations.js';
app.get('/organizations', organizationsPage);
When you are finished, all of the callback functions (the controller logic) will be in src/controllers/*.js files, and then all of the routing logic will be in src/controllers/routes.js file.
Then, your server.js file will import the routes from src/controllers/routes.js and use them with app.use().
The following steps will guide you through all of these changes.
Create Controller Modules
- Create a new folder named
controllers/in yoursrc/folder. - Create the following files in your
src/controllers/folder—one for each major feature of your application:src/controllers/index.jssrc/controllers/organizations.jssrc/controllers/projects.jssrc/controllers/categories.jssrc/controllers/errors.js
Move the controller callback functions to the controllers files
You will move the callback controller functions from server.js to the new controllers you just created and give them each a name.
The following table indicates which functions will be moved to which controller files:
| Move the callback from this route | To this Controller file |
|---|---|
app.get('/', ...) |
src/controllers/index.js |
app.get('/organizations', ...) |
src/controllers/organizations.js |
app.get('/projects', ...) |
src/controllers/projects.js |
app.get('/categories', ...) |
src/controllers/categories.js |
app.get('/test-error', ...) |
src/controllers/errors.js |
When you move the function you need to:
- Give the function a name (for example:
showOrganizationsPage) - At the top of the controller file, make sure to import any models functions you need.
- At the bottom of the controller file, export the controller functions so they can be imported and used later in the
routes.jsfile.
First, move the home page callback controller function to the src/controllers/index.js file. It should look as follows:
// Import any needed model functions (none are needed for the home page, so this is empty)
// Define any controller functions
const showHomePage = async (req, res) => {
const title = 'Home';
res.render('home', { title });
};
// Export any controller functions
export { showHomePage };
Next, move the organizations callback controller function to the src/controllers/organizations.js file. It should look as follows:
// Import any needed model functions
import { getAllOrganizations } from '../models/organizations.js';
// Define any controller functions
const showOrganizationsPage = async (req, res) => {
const organizations = await getAllOrganizations();
const title = 'Our Partner Organizations';
res.render('organizations', { title, organizations });
};
// Export any controller functions
export { showOrganizationsPage };
Next, move the service projects callback controller function to the src/controllers/projects.js file. It should look as follows:
// Import any needed model functions
import { getAllProjects } from '../models/projects.js';
// Define any controller functions
const showProjectsPage = async (req, res) => {
const projects = await getAllProjects();
const title = 'Service Projects';
res.render('projects', { title, projects });
};
// Export any controller functions
export { showProjectsPage };
Next, move the categories callback controller function to the src/controllers/categories.js file. It should look as follows:
// Import any needed model functions
import { getAllCategories } from '../models/categories.js';
// Define any controller functions
const showCategoriesPage = async (req, res) => {
const categories = await getAllCategories();
const title = 'Service Categories';
res.render('categories', { title, categories });
};
// Export any controller functions
export { showCategoriesPage };
Be careful about relative paths
When importing model functions into your controller files, make sure to use the correct relative path. Before, these imports were relative to the root directory, so they started with ./src/models/. Now they are being imported from files in the src/controllers/ directory, so they need to begin with ../models/ instead.
Next, move the test-error callback function to the src/controllers/errors.js file. It should look as follows:
// Import any needed model functions (none are needed for the error pages, so this is empty)
// Define any controller functions
// Test route for 500 errors
const testErrorPage = (req, res, next) => {
const err = new Error('This is a test error');
err.status = 500;
next(err);
};
// Export any controller functions
export { testErrorPage };
Create the routes.js file
Create a new file named src/controllers/routes.js. This file will contain all of the routing logic for your application.
In this file, you will import all of the controller functions you just created and then define the routes that use those controller functions as route handlers.
Finally, you will export the router object to use in the server.js file.
Your src/controllers/routes.js file should look as follows:
import express from 'express';
import { showHomePage } from './index.js';
import { showOrganizationsPage } from './organizations.js';
import { showProjectsPage } from './projects.js';
import { showCategoriesPage } from './categories.js';
import { testErrorPage } from './errors.js';
const router = express.Router();
router.get('/', showHomePage);
router.get('/organizations', showOrganizationsPage);
router.get('/projects', showProjectsPage);
router.get('/categories', showCategoriesPage);
// error-handling routes
router.get('/test-error', testErrorPage);
export default router;
Notice that in this file, you are using a router object to define your routes instead of directly using the app object. You then export this router object to use in server.js.
Refactor server.js to use your new routes
Now that you have the Controllers in place, you can update server.js to use the router you created in src/controllers/routes.js.
First, at the top of your server.js file, next to your other import statements, import the router:
import router from './src/controllers/routes.js';
Then, you can also remove the following imports that are no longer used in server.js:
// REMOVE these from server.js
import { getAllOrganizations } from './models/organizations.js';
import { getAllProjects } from './models/projects.js';
import { getAllCategories } from './models/categories.js';
Next, you can replace all of the route definitions from server.js with a single line: app.use(router);. You will no longer need all the individual route definitions since they are now in src/controllers/routes.js.
Remove these route definitions from server.js:
// REMOVE these from server.js
app.get('/',
...
);
app.get('/organizations',
...
);
app.get('/projects',
...
);
app.get('/categories',
...
);
app.get('/test-error',
...
);
And replace them with this single line of code:
// Use the imported router to handle routes
app.use(router);
Using app.use(router); tells your Express application to use the routes defined in the router object for handling incoming requests. This keeps your server.js file clean and focused on application setup, while all routing logic is encapsulated in the router module.
Be careful about the order
As mentioned in the middleware learning activity, the order in which you use middleware and routes in Express matters. Make sure to place app.use(router); after any middleware that needs to run before your routes. It should come right before your catch-all route and global error handler.
Your server.js file should now look something like this:
import express from 'express';
import { fileURLToPath } from 'url';
import path from 'path';
import { testConnection } from './src/models/db.js';
import router from './src/controllers/routes.js';
// Define the the application environment
const NODE_ENV = process.env.NODE_ENV?.toLowerCase() || 'production';
// Define the port number the server will listen on
const PORT = process.env.PORT || 3000;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
/**
* Configure Express middleware
*/
// Serve static files from the public directory
app.use(express.static(path.join(__dirname, 'public')));
// Set EJS as the templating engine
app.set('view engine', 'ejs');
// Tell Express where to find your templates
app.set('views', path.join(__dirname, 'src/views'));
// Middleware to log all incoming requests
app.use((req, res, next) => {
if (NODE_ENV === 'development') {
console.log(`${req.method} ${req.url}`);
}
next(); // Pass control to the next middleware or route
});
// Middleware to make NODE_ENV available to all templates
app.use((req, res, next) => {
res.locals.NODE_ENV = NODE_ENV;
next();
});
// Use the imported router to handle routes
app.use(router);
// Catch-all route for 404 errors
app.use((req, res, next) => {
const err = new Error('Page Not Found');
err.status = 404;
next(err);
});
// Global error handler
app.use((err, req, res, next) => {
// Log error details for debugging
console.error('Error occurred:', err.message);
console.error('Stack trace:', err.stack);
// Determine status and template
const status = err.status || 500;
const template = status === 404 ? '404' : '500';
// Prepare data for the template
const context = {
title: status === 404 ? 'Page Not Found' : 'Server Error',
error: err.message,
stack: err.stack
};
// Render the appropriate error template
res.status(status).render(`errors/${template}`, context);
});
app.listen(PORT, async () => {
try {
await testConnection();
console.log(`Server is running at http://127.0.0.1:${PORT}`);
console.log(`Environment: ${NODE_ENV}`);
} catch (error) {
console.error('Error connecting to the database:', error);
}
});
Test your application
Now that you have refactored your application to use the MVC pattern, it is important to test everything to ensure it still works as expected.
First, test your application locally, then, deploy it to your hosting environment and test it there as well.
If you encounter any problems, make sure you fix them before moving on.
Next Step
Complete the other Week 03 Learning Activities
After you have completed all the learning activities for this lesson, return to Canvas to submit a quiz.
Other Links:
- Return to: Week Overview | Course Home