MVC - How we use the Model-View-Controller pattern to separate concerns

·

6 min read

My first simple CRUD app probably has 500 lines of code in my server.js file. It was a pain in the neck to run testing, debug, or add new 'features'. Think about how difficult it would be if we were working as a team on it!

MVC According to our beloved MDN web docs: MVC (Model-View-Controller) is a pattern in software design commonly used to implement user interfaces, data, and controlling logic. It emphasizes a separation between the software's business logic and display. This "separation of concerns" provides for a better division of labor and improved maintenance. Some other design patterns are based on MVC, such as MVVM (Model-View-Viewmodel), MVP (Model-View-Presenter), and MVW (Model-View-Whatever).

It's a pattern for organizing code.

Model - manages data: It is in my opinion the most simple part of the MVC pattern, because this file only keeps the data model, and does not involve any DOM manipulation.

mongoose Jk. Mongoose is a Node.js-based Object Data Modeling (ODM) library for MongoDB.

Mongoose Schema vs. Model A Mongoose model is a wrapper on the Mongoose schema. A Mongoose schema defines the structure of the document, default values, validators, etc., whereas a Mongoose model provides an interface to the database for creating, querying, updating, deleting records, etc.

const mongoose = require('mongoose') // Referencing mongoose

// Define the Schema by constructing a mongoose instance where the key name corresponds to the property name in the collection
const TodoSchema = new mongoose.Schema({
  // Define a property called 'todo' with a schema type String which maps to an internal validator that will be triggered when the model is saved to the database. It will fail if the value is not a string.
  todo: {
    type: String,
    required: true,
  },
  completed: {
    type: Boolean,
    required: true,
  }
})

// Exporting the module by calling the model constructor on the Mongoose instance and pass it the name of the collection and a reference to the schema definition.
module.exports = mongoose.model('Todo', TodoSchema)

View - Handles layout and display The view is what the user sees and interacts with--the UI of the application. It generates the HTML for the browser to render. Based on the user request, a view using an ejs template can look as simple as:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h1>Create A Todo List By Clicking The Button Below</h1>
    <a href="/todos"> New Todo List</a>
</body>
</html>

or, it can display data from the database, such as:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="css/style.css">
</head>
<body>
    <h1>Todos</h1>
    <ul>
    <% todos.forEach( el => { %>
        <li class='todoItem' data-id='<%=el._id%>'>
            <span class='<%= el.completed === true ? 'completed' : 'not'%>'><%= el.todo %></span>
            <span class='del'> Delete </span>
        </li>
    <% }) %>    
    </ul>

    <h2>Things left to do: <%= left %></h2>

    <form action="/todos/createTodo" method='POST'>
        <input type="text" placeholder="Enter Todo Item" name='todoItem'>
        <input type="submit">
    </form>

    <script src="js/main.js"></script>
</body>
</html>

#Controller - Routes commands to the model and view parts To help understand how controllers work, start with the server.js code:

const express = require('express')
const app = express()

// connectDB is an async function that connects to the database
const connectDB = require('./config/database') 

const homeRoutes = require('./routes/home')
const todoRoutes = require('./routes/todos')

require('dotenv').config({path: './config/.env'})

connectDB()

app.set('view engine', 'ejs')
app.use(express.static('public'))
app.use(express.urlencoded({ extended: true }))
app.use(express.json())

app.use('/', homeRoutes) 
app.use('/todos', todoRoutes)

app.listen(process.env.PORT, ()=>{
    console.log('Server is running, you better catch it!')
})

The server listens for '/' and '/todos', and respectively, refers to home router and todos router.

Let's first look at the home.js in the routes folder:

const express = require('express')
const router = express.Router()
const homeController = require('../controllers/home')

router.get('/', homeController.getIndex) 

module.exports = router

Home router makes a get request at the root and calls the getIndex defined in the home controller:

module.exports = {
    getIndex: (req,res)=>{
        res.render('index.ejs')
    }
}

The todos router does more work:

const express = require('express')
const router = express.Router()
const todosController = require('../controllers/todos')

router.get('/', todosController.getTodos)

router.post('/createTodo', todosController.createTodo)

router.put('/markComplete', todosController.markComplete)

router.put('/markIncomplete', todosController.markIncomplete)

router.delete('/deleteTodo', todosController.deleteTodo)

module.exports = router

It can:

  • make a get request at it's root, which is '/todos' by calling getTodos defined in the todos controller;

  • make a post request at '/todos/createTodo' by calling createTodo;

  • make a put request at 'todos/markComplete' by calling markComplete;

  • make a put request at 'todos/makeIncomplete' by calling markIncomplete;

  • make a delete request at 'todos/deleteTodo' by calling deleteTodo.

Now let's go to todos controller to see how these functions are defined and how the controller processes these requests and work with the database:

const Todo = require('../models/Todo')

module.exports = {
    getTodos: async (req,res)=>{
        try{
            const todoItems = await Todo.find()
            const itemsLeft = await Todo.countDocuments({completed: false})
            res.render('todos.ejs', {todos: todoItems, left: itemsLeft})
        }catch(err){
            console.log(err)
        }
    },
    createTodo: async (req, res)=>{
        try{
            await Todo.create({todo: req.body.todoItem, completed: false})
            console.log('Todo has been added!')
            res.redirect('/todos')
        }catch(err){
            console.log(err)
        }
    },
    markComplete: async (req, res)=>{
        try{
            await Todo.findOneAndUpdate({_id:req.body.todoIdFromJSFile},{
                completed: true
            })
            console.log('Marked Complete')
            res.json('Marked Complete')
        }catch(err){
            console.log(err)
        }
    },
    markIncomplete: async (req, res)=>{
        try{
            await Todo.findOneAndUpdate({_id:req.body.todoIdFromJSFile},{
                completed: false
            })
            console.log('Marked Incomplete')
            res.json('Marked Incomplete')
        }catch(err){
            console.log(err)
        }
    },
    deleteTodo: async (req, res)=>{
        console.log(req.body.todoIdFromJSFile)
        try{
            await Todo.findOneAndDelete({_id:req.body.todoIdFromJSFile})
            console.log('Deleted Todo')
            res.json('Deleted It')
        }catch(err){
            console.log(err)
        }
    }
}

At the beginning of the code we can see that the todos controller required the Todo model. Each of the methods referred in the todos router is an asynchronous function that uses the Todo model to find, add, update or delete data in the database.

getTodos: async (req,res)=>{
        try{
            const todoItems = await Todo.find()
            const itemsLeft = await Todo.countDocuments({completed: false})
            res.render('todos.ejs', {todos: todoItems, left: itemsLeft})
        }catch(err){
            console.log(err)
        }
    },

getTodo will first find all data in the Todo model, and count the number of documents with the 'completed' field/property set as 'false'. Then, it respond by rendering the todos.ejs while passing in these values. This is a perfect example showing how controller connects the model and the view.

In the todos.ejs, in addition to listing the 'todo' values in each document, we are storing the document _id in the

  • 's data- attribute, to be used in the update and delete requests. We also used a ternary operator to add the corresponding class based on the 'completed' status of each document.

      <h1>Todos</h1>
          <ul>
          <% todos.forEach( el => { %>
              <li class='todoItem' data-id='<%=el._id%>'>
                  <span class='<%= el.completed === true ? 'completed' : 'not'%>'><%= el.todo %></span>
                  <span class='del'> Delete </span>
              </li>
          <% }) %>    
          </ul>
    
          <h2>Things left to do: <%= left %></h2>
    

    And this is how the data- attribute of

  • is used to find the document and update or delete it. Todo.findOneAndUpdate({_id:req.body.todoIdFromJSFile},{completed: false}) Todo.findOneAndDelete({_id:req.body.todoIdFromJSFile})

    Note that the update and delete requests are sent by the fetch requests from client-side js:

      async function deleteTodo(){
          const todoId = this.parentNode.dataset.id
          try{
              const response = await fetch('todos/deleteTodo', {
                  method: 'delete',
                  headers: {'Content-type': 'application/json'},
                  body: JSON.stringify({
                      'todoIdFromJSFile': todoId
                  })
              })
              const data = await response.json()
              console.log(data)
              location.reload()
          }catch(err){
              console.log(err)
          }
      }
    

    This is just a simple demonstration of how the MVC works. The pattern and the reason for using it feels similar to OOP. There are so many tools to store data (mongoDB, SQL, etc.) and to display UI (React, ejs, handlebars, etc.). If we follow MVC and separate the concerns, we can swap out views or databases and the app would still work.

    All code can be found here and thanks to Leon Noel (twitter @leonnoel) and 100Devs.