Skip to content
Express.js ex patterns 4 min read

The MVC Pattern

As an Express app grows, cramming database queries, business logic, and HTML rendering into one route handler quickly becomes unmaintainable. The Model-View-Controller (MVC) pattern fixes this by splitting an application into three cooperating layers: models that own the data, views that present it, and controllers that handle the request logic in between. Express does not enforce MVC — it is a deliberately unopinionated framework — but it gives you everything (routers, view engines, middleware) you need to apply the pattern cleanly. The payoff is code that is easier to test, reason about, and change.

The three layers mapped to Express

Each MVC layer has a natural home in an Express project. The table below maps the concept to the concrete pieces you already use.

LayerResponsibilityIn Express
ModelData shape, persistence, business rulesA module wrapping a DB driver / ORM (e.g. a Mongoose or Knex model)
ViewPresentation of data to the clientA template (EJS, Pug, Handlebars) or a JSON serializer
ControllerTranslate requests into model calls and pick a viewPlain functions that act as route handlers

Routes themselves are the wiring: they map an HTTP method and path to a controller function. Keeping that wiring thin — no logic, just references — is what makes the separation real.

Models — owning the data

A model encapsulates how data is stored and retrieved so that nothing else in the app needs to know whether you use PostgreSQL, MongoDB, or an in-memory array. Below is a small model backed by Knex; the rest of the app only ever sees its async methods.

// models/user.js
const db = require("../db"); // a configured Knex instance

async function findAll() {
  return db("users").select("id", "name", "email");
}

async function findById(id) {
  return db("users").where({ id }).first();
}

async function create({ name, email }) {
  const [row] = await db("users").insert({ name, email }).returning("*");
  return row;
}

module.exports = { findAll, findById, create };

Tip: Models should never touch req or res. Keeping them HTTP-agnostic means you can reuse them from a CLI script, a background job, or a test with zero changes.

Controllers — the request logic

A controller is a function (or a group of them) that receives the request, calls the appropriate model methods, and decides what to send back — either a rendered view or JSON. Using async/await keeps the flow readable, and a next(err) call hands failures to your error-handling middleware.

// controllers/userController.js
const User = require("../models/user");

async function list(req, res, next) {
  try {
    const users = await User.findAll();
    res.render("users/index", { users }); // view path + data
  } catch (err) {
    next(err);
  }
}

async function show(req, res, next) {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).render("404");
    res.render("users/show", { user });
  } catch (err) {
    next(err);
  }
}

module.exports = { list, show };

In an API-only app the controller simply calls res.json(users) instead of res.render(...); the structure is identical.

Views — presenting the data

Views are templates that turn data into a response. Register a view engine once, then res.render(name, data) finds the template and passes it the locals.

// app.js (view engine setup)
const express = require("express");
const path = require("path");

const app = express();
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
<!-- views/users/index.ejs -->
<ul>
  <% users.forEach(user => { %>
    <li><a href="/users/<%= user.id %>"><%= user.name %></a></li>
  <% }); %>
</ul>

Wiring it together with routes

Routes are where the layers meet. A dedicated router file references controller functions by name — no logic lives here, which is exactly the point. This keeps the routing table readable as a single source of truth for your URL surface.

// routes/users.js
const express = require("express");
const router = express.Router();
const userController = require("../controllers/userController");

router.get("/", userController.list);
router.get("/:id", userController.show);

module.exports = router;
// app.js (mounting + error handler)
const usersRouter = require("./routes/users");

app.use("/users", usersRouter);

// Centralized error middleware (must have 4 args)
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).render("500", { message: err.message });
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));

A request now flows predictably: GET /users/42 hits the router, the router calls userController.show, the controller asks User.findById(42), and the result is handed to the users/show view.

Output:

$ curl -s http://localhost:3000/users/42
<h1>Grace Hopper</h1><p>[email protected]</p>

Note: Express 5.x automatically forwards rejected promises from async route handlers to your error middleware, so the explicit try/catch in controllers becomes optional. On Express 4.x the try/catch (or a wrapper) is still required.

Best practices

  • Keep controllers thin — they should orchestrate, not implement. Push real business rules down into models or a service layer.
  • Never let models reference req/res; this keeps them reusable and unit-testable in isolation.
  • Use one router file per resource and mount it under a clear prefix (/users, /orders) to mirror your folder structure.
  • Reference controller functions in routes by name; resist writing inline handlers so the routing table stays a clean map of your API.
  • Funnel all errors through a single error-handling middleware rather than rendering error pages from each controller.
  • As logic outgrows controllers, extract a service layer between controllers and models instead of fattening either side.
Last updated June 14, 2026
Was this helpful?