Controllers — Separating Request Handling from Application Logic
Master professional backend code organization. Learn why cramming database queries, validation rules, and business logic into route definitions creates bloated, untestable applications. Discover how controllers isolate HTTP transport mechanics, delegate domain operations to dedicated services, and engage in an authentic hands-on Code Refactoring Lab.
What is a Controller?
Understanding the controller's role as the intermediary translating HTTP requests into application operations.
The Request-Response Separation of Concerns
When building backend applications, beginners often place all logic directly inside their route callbacks. While this works for tiny 5-line tutorials, real-world backends rapidly become unmaintainable when routing, input validation, business calculations, SQL queries, and status formatting are tangled together.
Binds HTTP method + path to a handler. Contains zero business logic or database queries.
Extracts req.params and req.body, invokes services, selects HTTP status (200, 201, 400), and sends JSON.
Pure business logic (e.g. creating records, checking permissions, sending emails) completely free of Express or HTTP objects.
Before vs After: Refactoring a Tasks API
Observe the dramatic improvement in readability, maintainability, and testing when fat routes are refactored.
// ❌ Fat route: Everything mashed together!
router.post('/tasks', async (req, res) => {
const { title, priority } = req.body;
// Validation mixed with route
if (!title || title.trim().length === 0) {
return res.status(400).json({ error: 'Title required' });
}
// Business rules mixed in
const taskPriority = priority || 'medium';
// Direct database query mixed in
const result = await db.query(
'INSERT INTO tasks (title, priority) VALUES ($1, $2) RETURNING *',
[title, taskPriority]
);
// Response formulation
return res.status(201).json(result.rows[0]);
});// routes/taskRoutes.js
// ✅ Route is one clean line!
router.post('/tasks', createTask);
// controllers/taskController.js
// ✅ Controller handles only HTTP transport concerns!
async function createTask(req, res) {
const { title, priority } = req.body;
if (!title?.trim()) {
return res.status(400).json({ error: 'Title required' });
}
// Delegate domain operation to service
const task = await taskService.createTask({ title, priority });
return res.status(201).json(task);
}Controller Responsibilities: The DOs and DON'Ts
Clear boundaries establishing what belongs in the controller layer versus what belongs in services or models.
✅ What BELONGS in a Controller
- Extracting Request Inputs: Reading
req.params,req.query, andreq.body. - Basic Input Validation: Confirming required parameters are present before invoking services.
- Delegating to Services: Calling business operations (e.g.
taskService.createTask(...)). - Selecting HTTP Status Codes: Choosing
200 OK,201 Created,400 Bad Request, or404 Not Found. - Setting Response Headers: Setting content-type or custom metadata headers.
- Error Delegation: Passing unhandled exceptions to centralized error middleware with
next(err).
🚫 What DOES NOT Belong in a Controller
- Direct Database SQL Queries: Writing
SELECT * FROM tasksdirectly in the handler. - Complex Business Logic: Formulas like calculating tax, discounts, or processing payment gateways.
- Low-level Cryptography: Writing bcrypt hashing algorithms inline.
- Third-party API Calls: Directly invoking external Stripe or SendGrid SDKs inside the route handler.
- Repeated Utility Functions: String formatting or date calculation functions.
Real Full Stack Architecture Example: Task API
A professional project layout separating routes, controllers, and services in a production-ready structure.
src/
├── routes/ # Maps URLs & HTTP Verbs to Controllers
│ └── taskRoutes.js # router.post('/tasks', createTask);
│
├── controllers/ # Translates HTTP requests & formats HTTP responses
│ └── taskController.js# Extracts params, calls taskService, returns res.json()
│
├── services/ # Contains pure domain & business logic
│ └── taskService.js # Calculates status, coordinates DB updates
│
├── models/ # Database schemas & direct queries
│ └── taskModel.js # PostgreSQL / MySQL queries or ORM models
│
└── app.js # Initializes Express, mounts routersActual Code Refactoring Lab (Main Activity)
Edit real backend files below. Refactor the working but bloated route in routes/taskRoutes.js into a clean route + controller layer in controllers/taskController.js.
Backend Refactoring Environment
Edit the files below to decouple route binding from controller execution.
Express 5.x vs FastAPI Implementation
Controllers are an architectural responsibility, not a mandatory framework class. See how both frameworks implement this pattern.
// Express Controller function
async function getTaskById(req, res) {
const { id } = req.params;
const task = await taskService.find(id);
if (!task) return res.status(404).json({ error: 'Not found' });
return res.status(200).json(task);
}
module.exports = { getTaskById };
// Wire in routes/taskRoutes.js:
// router.get('/tasks/:id', getTaskById);# FastAPI path operation function acts as controller
from fastapi import APIRouter, HTTPException
from services import task_service
router = APIRouter()
@router.get("/tasks/{task_id}")
async def get_task(task_id: int):
task = await task_service.find(task_id)
if not task:
raise HTTPException(status_code=404, detail="Not found")
return taskDebugging & 4 Common Controller Mistakes
Inspect real-world controller bugs, examine error signatures, and learn precise production fixes.
1. Controller Function Not Exported
// ❌ BUGGY: Forgot module.exports!
async function getTask(req, res) {
res.json({ id: 1, title: 'Learn Controllers' });
}
// Missing: module.exports = { getTask };// ✅ FIXED: Export the controller function
async function getTask(req, res) {
res.json({ id: 1, title: 'Learn Controllers' });
}
module.exports = { getTask };Why this happens: In Node.js, functions defined in a file are private to that module unless explicitly exported via module.exports or export. Requiring an unexported function returns undefined, causing Express to throw a TypeError during server startup.
Mini Challenge: Multi-Resource Refactoring
A Learning Platform API has 3 bloated endpoints: GET /api/courses, POST /api/enrollments, and PATCH /api/users/:id.
Inspect how the three endpoints map cleanly across the architectural boundaries:
routes: router.get('/courses', getCourses) → controller: courseController.getCourses → service: courseService.list()routes: router.post('/enrollments', enrollStudent) → controller: enrollController.create → service: enrollService.register()routes: router.patch('/users/:id', updateUser) → controller: userController.update → service: userService.updateProfile()Full Stack Request Lifecycle & Recap
Summary of the 4 core backend layers and their strict boundaries.
| Layer | Core Question Answered | Key Responsibility | Example Code |
|---|---|---|---|
| Route | "Which code handles this URL + method?" | URL matching, HTTP verb binding | router.get('/tasks', getTasks) |
| Controller | "How do we process the HTTP dialogue?" | Extract params, invoke service, pick status code | res.status(200).json(task) |
| Service | "What business operation needs to happen?" | Calculations, validations, domain rules | taskService.completeTask(id) |
| Database | "Where is data stored and retrieved?" | SQL execution, persistence, indexing | SELECT * FROM tasks WHERE id = $1 |
Controllers Mastery Quiz
Test your understanding of backend controller architecture, separation of concerns, and Express 5/FastAPI patterns.
🌐 What is the primary architectural purpose of a Controller in a backend web application?
🧠 The 5 Golden Rules of Backend Controllers
req/res. This makes them 100% reusable and easily unit-tested.