NodeJSThe MVC Pattern

The MVC Pattern

Model-View-Controller (MVC) is the architectural pattern underlying most web frameworks — Rails, Django, Laravel, and in spirit, Express. It separates an application into three collaborating roles: the Model (data and business logic), the View (presentation, often a template or JSON response), and the Controller (orchestrates: handles the request, asks the model for data, gives it to the view). For API-only Node backends the "view" is usually just JSON serialization rather than HTML templates, but the separation is identical. Understanding MVC clarifies why the layered architecture looks the way it does.

The three roles

Role

Responsibility

In a Node API

Model

Data structure, business rules, persistence, validation

Service + repository + type/schema definitions

View

Presentation — renders data for the user

JSON serialization, response formatting (or a template engine for SSR)

Controller

Receives input, coordinates model & view

Express route handler — parses req, calls service, sends res

MVC separates input handling (C), data/rules (M), and presentation (V) — each changes for different reasons
MVC is a **separation of change reasons**. The **Controller** changes when the API's interface changes — a new parameter, a different route. The **Model** changes when business rules or data structure change. The **View** changes when the response format changes — a new field, a different serialization. Because these reasons are independent, keeping them in separate classes/modules means each change is local: rename a field in the response without touching business logic; add a new business rule without touching routes; change validation without touching data access. In a Node REST API, "View" is usually just `res.json(user)` — thin and barely worth naming — but the principle is the same: don't mix serialization decisions into business logic or routing.
MVC in Express

TS
// MODEL — data shape + business logic (service + repo)
// users.service.ts
export class UsersService {
  async getById(id: string): Promise<User> {
    const user = await this.repo.findById(id)
    if (!user) throw new NotFoundError(`User ${id} not found`)
    return user
  }
}

// VIEW — response serialization (often inline, sometimes a presenter/DTO class)
function userView(user: User) {
  return { id: user.id, name: user.name, email: user.email }   // never expose password hash
}

// CONTROLLER — coordinates: parse input, call model, render view
// users.controller.ts
export async function getUser(req: Request, res: Response) {
  const user = await usersService.getById(req.params.id)
  res.json(userView(user))
}
In a Node API, the controller is the route handler; the model is services + repos; the view is the JSON serializer or a DTO mapper
Express doesn't enforce MVC, but you apply the pattern by convention. The **route handler** is the controller: it parses `req.params`/`req.body`, calls the relevant service method (the model), and passes the result to a **view function** (a serializer/DTO mapper) before `res.json()`. The view function is important even though it feels trivial: it's the explicit boundary between your *internal domain model* (a `User` with a password hash, audit fields, internal flags) and the *external representation* you expose to clients (a subset of fields, possibly renamed, possibly aggregated). That boundary prevents accidentally leaking the password hash or internal fields when you add a new property to the User model — the view function must be explicitly updated to expose it.
MVC vs layered — the same thing?
MVC and layered architecture describe the same code from different angles — MVC names the roles, layered names the dependency direction
MVC and [layered architecture](/nodejs/layered-architecture) are complementary descriptions of the same design. **MVC names the three *roles***: Controller handles input, Model owns data and rules, View renders output. **Layered architecture names the *dependency direction***: presentation depends on service which depends on repository — dependencies flow downward, never upward. The "Model" in MVC spans roughly the service + repository layers; the "View" is the JSON serialization step at the top of the presentation layer; the "Controller" is the route handler. Understanding both gives you the *why* (MVC — change isolation) and the *how* (layered — where to put code, which way imports flow). Neither is a strict framework — they're vocabulary for discussing design decisions and a shared mental model for code review.
A 'fat controller' that does everything is the classic MVC failure — move logic down to the model (service layer)
The canonical MVC anti-pattern is the **fat controller**: a route handler that queries the database, applies business rules, formats responses, sends emails, and logs — hundreds of lines of mixed-concern code. This happens when developers mistake "controller" for "where the work happens." The controller's job is to *orchestrate*, not *execute*: parse and validate input, call the right service methods, handle the result. The work lives in the **model** (service + repository). When a controller grows beyond ~30 lines, it's almost certainly doing work that belongs in a service. The discipline of asking "does this belong in the model?" before writing a line in a controller is what prevents fat-controller drift.
Next
The principle underlying all of this: [Separation of Concerns](/nodejs/separation-of-concerns).