Skip to main content

What is MVC architecture pattern?

MVC (Model-View-Controller) is an architecture pattern that splits an application into three components: Model handles data and business logic, View handles the UI, and Controller sits between them handling user input.

Theory

TL;DR

  • Model = data layer (queries, validation, state); View = what the user sees; Controller = the coordinator between the two
  • Analogy: a restaurant. The kitchen (Model) manages food. The plate and presentation (View) is what reaches the customer. The waiter (Controller) takes orders and connects them.
  • Core flow: user acts in View, Controller updates Model, Model notifies View
  • Use MVC when UI logic and data logic need to grow independently
  • Skip it for scripts under ~500 lines or pure API services with no View layer

Quick Example

javascript
// Model: data logic only const UserModel = { users: [{ id: 1, name: 'Alice' }], getUser(id) { return this.users.find(u => u.id === id); } }; // View: rendering only const UserView = { render(user) { document.getElementById('app').innerHTML = `<h1>${user.name}</h1>`; } }; // Controller: handles input, connects the other two const UserController = { load(id) { const user = UserModel.getUser(id); UserView.render(user); // Output: <h1>Alice</h1> } }; UserController.load(1);

Each piece has one job. Model does not know how the UI looks. View does not know where data comes from. Controller knows about both, but only to connect them.

How the Three Components Actually Connect

Model holds state and exposes methods to read or modify it. It knows nothing about views or controllers. View receives data and renders it. It has no idea where that data came from. Controller listens for user events, calls Model methods, then tells View what to display.

The direction is always the same: user action hits Controller, Controller talks to Model, data comes back, Controller passes it to View. No shortcuts. View never calls Model directly.

This one-way discipline is what separates MVC from older patterns where UI and data were tangled together. Once you skip that rule, components start fetching their own data, triggering side effects on render, and breaking every time the data layer changes.

When to Use MVC

  • Web app with a real UI and backend data: MVC (Rails, ASP.NET MVC, Spring MVC)
  • Express.js API with templates: fits cleanly (routes act as Controllers, EJS or Handlebars are Views)
  • React SPA with complex state: consider Flux or Redux instead (MVC gets awkward with bidirectional React state)
  • CLI tool or small script: skip the pattern, plain functions work fine
  • Team of 5+ developers: MVC lets people work on separate layers in parallel without stepping on each other
  • Microservice with no UI: Model and Controller only, no View layer needed

Comparison: MVC vs Monolith vs Flux

AspectMVCMonolithFlux/Redux
Data flowController to Model to ViewOften bidirectionalStrictly unidirectional
TestingIsolate layers independentlyRequires full app mocksUnit test actions and reducers
Parallel devTeams work on separate layersSingle dev bottleneckGlobal state, harder to split
Best forTraditional web apps (Rails, ASP.NET)Prototypes under 1k LOCReact apps with complex client state

How It Works at Runtime

In a browser, a click event reaches the Controller first via addEventListener. The Controller calls a Model method, which may run an async database query through fetch or an ORM like Sequelize. Once data is ready, the Model can emit a custom event or use a pub/sub mechanism. The View listens and re-renders.

In frameworks like Rails, this happens at the HTTP level: a request maps to a Controller action, which calls Model methods, and the result gets passed to a View template (ERB, Handlebars, Thymeleaf). Same pattern, just over HTTP instead of DOM events.

Common Mistakes

1. View fetching data directly

javascript
// Bad: View is tightly coupled to Model function UserView() { const user = UserModel.getUser(1); // breaks when Model goes async return `<div>${user.name}</div>`; } // Good: Controller mediates UserController.load(1); // Controller calls Model, passes result to View

View fetching its own data seems convenient until the data source changes. Then you are editing every View instead of one Model method.

2. Fat Controller

The Controller ends up doing database queries, validation, business logic, and rendering. 500+ lines, impossible to test. The fix: Controller only calls Model methods and View methods. All logic stays in Model.

3. Stale View after Model update

javascript
// Bad: Model updates but View never knows Model.updateUser(id, data); View.render(null); // stale data shown to user // Good: Model emits event, View re-renders Model.updateUser(id, data); // triggers 'change' event model.addEventListener('change', (e) => View.render(e.detail));

This is the most common "MVC View not updating" issue on Stack Overflow. The Model update happened but nobody told the View.

4. Synchronous Model in Node.js

Blocking the event loop with sync database calls hangs the entire server. Always use async/await for Model methods. The Controller catches errors from rejected Promises and passes an error state to the View.

Real-World Usage

  • Ruby on Rails 7: app/models/user.rb, app/views/users/show.html.erb, app/controllers/users_controller.rb
  • ASP.NET Core 8: Models/User.cs, Views/User/Index.cshtml, Controllers/UserController.cs
  • Spring MVC (Java): @Service for Model logic, Thymeleaf for View, @Controller for routing
  • Backbone.js 1.4: built around MVC with model.bind() connecting directly to view.render()
  • Django uses a variant called MVT (Model-View-Template) where the "View" behaves closer to a Controller

Follow-up Questions

Q: Draw the MVC flow for a user submitting an edit form.
A: User fills in and submits the form (View). Controller catches onSubmit, validates input, calls Model.save(). Model persists the data and emits a change event. Controller receives the event and tells View to render a success message.

Q: How do you test each MVC layer independently?
A: For Model tests, use a test database or mock the ORM. For Controller tests, stub the Model with sinon.js and verify that the correct View methods get called. For View tests, pass a data fixture directly and snapshot the output.

Q: What is the difference between MVC and MVVM?
A: In MVVM (Vue.js, Angular), the ViewModel binds directly to the View two-way. Changes in View automatically update ViewModel and vice versa. In MVC, the Controller is not bound to View data at all. It passes data through explicitly.

Q: How do you handle Model errors in the View?
A: Controller wraps the Model call in try/catch, or handles Promise rejection, then calls View.renderError(error) instead of View.render(data). View gets an error state object, not a raw exception.

Q: How would you scale MVC across micro-frontends?
A: Each micro-frontend gets its own Controller and View. Shared Model state flows through an event bus (RxJS Subject or a custom EventEmitter). Shadow DOM isolates View styles and events so micro-apps do not bleed into each other.

Q: With React hooks available, does MVC still apply in 2024?
A: Hooks are Controller logic extracted into reusable functions. useEffect handles side effects the same way a Controller would. Next.js App Router closely mirrors MVC: server components act as Model and Controller, client components act as View. The pattern is alive, just not always labeled.

Examples

Basic MVC: Loading a User in Vanilla JS

javascript
// Model const UserModel = { users: [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' } ], getUser(id) { return this.users.find(u => u.id === id) || null; } }; // View const UserView = { render(user) { const app = document.getElementById('app'); if (!user) { app.innerHTML = '<p>User not found</p>'; return; } app.innerHTML = `<h1>${user.name}</h1><p>${user.email}</p>`; } }; // Controller const UserController = { init() { document.getElementById('load-btn').addEventListener('click', () => { const user = UserModel.getUser(1); UserView.render(user); // Output: Alice / alice@example.com }); } }; UserController.init();

Model has no idea there is a button or a DOM node. View has no idea where users come from. That is the whole point of the separation.

Intermediate: Express.js REST Controller

javascript
const express = require('express'); const Database = require('better-sqlite3'); const db = new Database('app.db'); const app = express(); app.use(express.json()); // Model: database access only const UserModel = { getUser(id) { return db.prepare('SELECT * FROM users WHERE id = ?').get(id); }, updateUser(id, data) { db.prepare('UPDATE users SET name = ? WHERE id = ?').run(data.name, id); return this.getUser(id); } }; // Controller: Express route handlers act as the Controller layer app.get('/users/:id', (req, res) => { const user = UserModel.getUser(req.params.id); if (!user) return res.status(404).json({ error: 'Not found' }); res.json(user); // Output: { id: 1, name: 'Alice' } }); app.put('/users/:id', (req, res) => { const updated = UserModel.updateUser(req.params.id, req.body); res.json(updated); // Output: { id: 1, name: 'Bob' } }); // View: the JSON response is the View in a REST API

In a REST API the View is the JSON response. The separation still holds: Model talks to the database, Controller handles request and response, View is assembled from the data the Controller provides.

Advanced: Model with Pub/Sub, View Subscribes Directly

javascript
// Model with event notification (no knowledge of who listens) class UserModel extends EventTarget { constructor() { super(); this.data = { id: 1, name: 'Alice' }; } update(name) { this.data.name = name; this.dispatchEvent(new CustomEvent('change', { detail: { ...this.data } })); } } const model = new UserModel(); // View subscribes to Model events (wired up by Controller on init) model.addEventListener('change', (e) => { document.getElementById('username').textContent = e.detail.name; // Output: DOM updates instantly without full page re-render }); // Controller document.getElementById('save-btn').addEventListener('click', () => { const newName = document.getElementById('name-input').value; model.update(newName); // fires 'change' event above });

One thing I noticed building this pattern in production: View subscribing to Model events is clean in vanilla JS, but in React 18+ it bypasses the reconciler entirely. Wrap Model-driven updates in useEffect, or use a state manager like Zustand, to stay inside React's rendering cycle.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?