# MVC — Model View Controller

## When the Django Template Knew Too Much

Early in my career I built a restaurant-facing dashboard in Django. The views were a mix of database queries, business rules, and template rendering. A single view function would fetch orders, calculate summary totals, apply tax rules, format currency, and pass it all to the template. When the tax rules changed, I had to find every view that applied them.

MVC is the answer to this. It forces a hard separation: the **Model** knows the data and rules, the **View** knows nothing except how to display, and the **Controller** sits between them, translating user actions into model operations and deciding which view to render.

## Table of Contents

* [The Three Roles](#the-three-roles)
* [Request Flow](#request-flow)
* [MVC in a Web Framework Context](#mvc-in-a-web-framework-context)
* [Python Example: FastAPI MVC](#python-example-fastapi-mvc)
* [TypeScript Example: Express MVC](#typescript-example-express-mvc)
* [MVC vs MVP vs MVVM](#mvc-vs-mvp-vs-mvvm)
* [Common Violations](#common-violations)
* [Lessons Learned](#lessons-learned)

***

## The Three Roles

```
┌───────────────────────────────────────────────────────┐
│                   User Interaction                    │
└───────────────────────────┬───────────────────────────┘
                            │ input (click, form submit)
                            ▼
            ┌───────────────────────────┐
            │        Controller         │
            │  - Receives user input    │
            │  - Validates input        │
            │  - Invokes model          │
            │  - Selects view to render │
            └──────────┬───────┬────────┘
                       │       │
            updates    │       │  selects
                       ▼       ▼
            ┌──────────┐     ┌──────────┐
            │  Model   │     │   View   │
            │ - Data   │────▶│ - Render │
            │ - Rules  │reads│ - Display│
            │ - State  │     │ - Format │
            └──────────┘     └──────────┘
```

**Model:** Encapsulates data, domain rules, and persistence. Has no knowledge of how it will be displayed. Can be a domain object, a service, or a repository collection.

**View:** Renders model data. Contains no business logic. May format data for display (currency, dates) but does not calculate, validate, or persist.

**Controller:** Translates HTTP requests (or UI events) into model operations. Picks the view to render. Contains coordination logic but no domain rules.

***

## Request Flow

```
HTTP Request
  │
  ▼
Controller.get_orders(tenant_id, date_filter)
  │
  ├─► Validate input (tenant_id present, date_filter parseable)
  │
  ├─► Model: order_service.list_orders(tenant_id, date_range)
  │     └─► Returns List[Order]
  │
  └─► View: render("orders/list.html", orders=orders)
        └─► Formats totals, statuses, dates — no logic
```

***

## MVC in a Web Framework Context

Different frameworks apply MVC slightly differently:

| Framework | Model                         | View                      | Controller                               |
| --------- | ----------------------------- | ------------------------- | ---------------------------------------- |
| Django    | `models.py` + managers        | Templates                 | `views.py` functions / class-based views |
| Rails     | Active Record models          | `.erb` templates          | Controllers                              |
| Laravel   | Eloquent models               | Blade templates           | Controllers                              |
| Express   | Service or repository classes | Template engine / JSON    | Route handlers                           |
| FastAPI   | Service/repository classes    | Pydantic response schemas | Routers                                  |

In REST APIs, the "view" is the serialised JSON response. Pydantic response models or serialiser classes play the view role.

***

## Python Example: FastAPI MVC

```
pos-api/
├── models/          ← Model layer
│   ├── order.py     # SQLAlchemy ORM + domain logic
│   └── product.py
├── services/        ← Extends Model layer (business rules)
│   └── order_service.py
├── controllers/     ← Controller layer (routes)
│   └── order_router.py
└── schemas/         ← View layer (response shapes)
    └── order_schema.py
```

```python
# models/order.py  — domain model with rules

from sqlalchemy import Column, Integer, String, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from .base import Base

class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    tenant_id = Column(String, nullable=False)
    status = Column(String, default="open")
    items = relationship("OrderItem", back_populates="order")

    def confirm(self):
        if not self.items:
            raise ValueError("Cannot confirm empty order")
        self.status = "confirmed"

    def void(self, reason: str):
        if self.status == "voided":
            raise ValueError("Already voided")
        self.status = "voided"
```

```python
# services/order_service.py  — orchestration logic

from sqlalchemy.orm import Session
from ..models.order import Order

class OrderService:
    def __init__(self, db: Session):
        self._db = db

    def get_orders_for_tenant(self, tenant_id: str, status: str | None = None) -> list[Order]:
        q = self._db.query(Order).filter(Order.tenant_id == tenant_id)
        if status:
            q = q.filter(Order.status == status)
        return q.order_by(Order.id.desc()).all()

    def confirm_order(self, order_id: int, tenant_id: str) -> Order:
        order = self._db.query(Order).filter(
            Order.id == order_id, Order.tenant_id == tenant_id
        ).first()
        if not order:
            raise ValueError("Order not found")
        order.confirm()
        self._db.commit()
        return order
```

```python
# schemas/order_schema.py  — view (response shape)

from pydantic import BaseModel

class OrderItem(BaseModel):
    product_name: str
    quantity: int
    unit_price: float

class OrderResponse(BaseModel):
    id: int
    status: str
    item_count: int
    total: float

    model_config = {"from_attributes": True}
```

```python
# controllers/order_router.py  — controller

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.order_service import OrderService
from ..schemas.order_schema import OrderResponse

router = APIRouter(prefix="/orders", tags=["orders"])

@router.get("/", response_model=list[OrderResponse])
def list_orders(
    tenant_id: str,
    status: str | None = None,
    db: Session = Depends(get_db)
):
    service = OrderService(db)
    orders = service.get_orders_for_tenant(tenant_id, status)
    return orders

@router.post("/{order_id}/confirm", response_model=OrderResponse)
def confirm_order(order_id: int, tenant_id: str, db: Session = Depends(get_db)):
    service = OrderService(db)
    try:
        order = service.confirm_order(order_id, tenant_id)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))
    return order
```

***

## TypeScript Example: Express MVC

```typescript
// models/orderModel.ts
export interface Order {
  id: number
  tenantId: string
  status: "open" | "confirmed" | "voided"
  total: number
}

// services/orderService.ts
import { db } from "../database"
import { Order } from "../models/orderModel"

export class OrderService {
  async getOrdersByTenant(tenantId: string): Promise<Order[]> {
    return db.query("SELECT * FROM orders WHERE tenant_id = $1 ORDER BY id DESC", [tenantId])
  }

  async confirmOrder(orderId: number, tenantId: string): Promise<Order> {
    const order = await db.queryOne("SELECT * FROM orders WHERE id = $1 AND tenant_id = $2", [orderId, tenantId])
    if (!order) throw new Error("Order not found")
    if (order.status !== "open") throw new Error("Order cannot be confirmed")
    return db.queryOne("UPDATE orders SET status = 'confirmed' WHERE id = $1 RETURNING *", [orderId])
  }
}

// controllers/orderController.ts
import { Request, Response } from "express"
import { OrderService } from "../services/orderService"

const orderService = new OrderService()

export async function listOrders(req: Request, res: Response) {
  const { tenantId } = req.query as { tenantId: string }
  if (!tenantId) return res.status(400).json({ error: "tenantId is required" })
  const orders = await orderService.getOrdersByTenant(tenantId)
  res.json(orders)
}

export async function confirmOrder(req: Request, res: Response) {
  const { id } = req.params
  const { tenantId } = req.body
  try {
    const order = await orderService.confirmOrder(parseInt(id), tenantId)
    res.json(order)
  } catch (err) {
    res.status(400).json({ error: (err as Error).message })
  }
}

// routes/orders.ts  — view routing
import { Router } from "express"
import { listOrders, confirmOrder } from "../controllers/orderController"

const router = Router()
router.get("/", listOrders)
router.post("/:id/confirm", confirmOrder)
export default router
```

***

## MVC vs MVP vs MVVM

| Concern                       | MVC                           | MVP                       | MVVM                      |
| ----------------------------- | ----------------------------- | ------------------------- | ------------------------- |
| Who handles user input?       | Controller                    | Presenter                 | ViewModel (via bindings)  |
| View knowledge of Model?      | Direct (via data)             | None — Presenter mediates | None — binds to ViewModel |
| Testability of "middle" layer | Medium — needs HTTP / context | High — pure class         | High — pure class         |
| Primary context               | Server-side web, REST APIs    | Android, desktop GUIs     | React, Vue, WPF           |

***

## Common Violations

* **Fat controller:** The controller contains business rules (tax calculation, inventory check). Move those to the service / model layer.
* **Fat model:** The model contains rendering logic (formatting currency, building HTML). Move that to the view / schema.
* **View calls the database:** The template executes a query. This breaks the pattern and makes N+1 query bugs invisible.
* **Controller bypasses the service:** The controller calls the ORM directly, duplicating query logic.

***

## Lessons Learned

* **MVC is a guideline, not a law.** In practice, the boundary between controller and service blurs. Having a named layer is more valuable than a perfect implementation.
* **Django's "views.py" is actually the controller.** Knowing this prevents beginners from putting everything in the template (which Django calls the view).
* **Thin controllers, thick services.** The more logic lives in the service layer, the more of it is testable without an HTTP request.
