Explorer
Node.js

Chapter 6: Building a Backend from Scratch: How a Node.js Server Actually Works

Chapter 6: Building a Backend from Scratch: How a Node.js Server Actually Works


What Are We Building?

Imagine you just landed a job as a Node.js developer. On day one, your team lead says:

"Build a REST API. Use Express. Connect it to MongoDB. Make it production-ready."

You could Google a tutorial, copy-paste some boilerplate, and have something running in 20 minutes. But when your lead asks "So what actually happens when a request hits your server?", you would be stuck.

This chapter is different. We are going to build a backend from absolute zero—no boilerplate, no generators, no magic. We will start with an empty file, and by the end, you will understand exactly what your computer is doing at every single step when it runs a backend server.


What You Will Learn

By the end of this chapter, you will be able to answer these questions confidently:

  • What happens inside your computer when you run node app.js?
  • What does server.listen(3000) actually do at the operating system level?
  • What is a socket? What is a file descriptor?
  • What is the difference between a TCP connection and an HTTP request?
  • How does Node.js handle thousands of users without freezing?
  • What does Express actually do? Does it replace Node's HTTP server?
  • What is middleware? How does next() work?
  • Why do we separate code into routes, controllers, and services?
  • How does a single HTTP request travel from the browser all the way to the database and back?

Our Final Project Structure

Here is the folder structure we will build towards. Don't worry about understanding it right now—we will build each file one at a time, and by the end it will all make sense:

TEXT
project/
├── package.json
├── app.js              ← The starting point of our entire application
├── db.js               ← Database connection setup
└── features/
    └── users/
        ├── user.model.js       ← Defines what a "user" looks like in the database
        ├── user.service.js     ← Business logic (the real work)
        ├── user.controller.js  ← Translates HTTP requests into service calls
        └── user.routes.js      ← Maps URLs to controller functions

Why are all user-related files grouped together in features/users/? Because in real projects, if you scatter your files across separate routes/, controllers/, and services/ folders, you end up jumping between 4 different directories just to make a simple change to the users feature. Keeping everything together makes life much easier.

Our package.json uses modern JavaScript modules (ESM):

JSON
{
  "name": "my-backend",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "mongoose": "^8.9.5"
  }
}

Now, let's start from scratch.


Part 1: Creating the Simplest Possible Server

Create a file called app.js and write this:

JAVASCRIPT
// app.js
import http from "node:http";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello World!\n");
});

server.listen(3000, () => {
  console.log("Server is running on port 3000");
});

Run it:

BASH
node app.js

Now open your browser and go to http://localhost:3000. You will see:

TEXT
Hello World!

Congratulations—you just built a web server! But let's not move on yet. Let's really understand what happened.


What Happened Inside Your Computer?

When you typed node app.js, a chain of events kicked off inside your machine. Let's break it down step by step.

Step 1: Node.js Starts as a Process

Your operating system (Windows, Mac, or Linux) created a new process—a running program with its own private memory space. Every process gets an ID number called a PID (Process ID):

TEXT
Your computer is running many processes:
├── Chrome Browser      (PID: 1234)
├── VS Code             (PID: 5678)
├── Spotify             (PID: 9012)
└── node app.js         (PID: 3456)  ← Our new process!

At this point, our Node process is just sitting in memory. It hasn't touched the network yet.

Step 2: http.createServer() Creates a Server Object

This line:

JAVASCRIPT
const server = http.createServer((req, res) => { ... });

creates a JavaScript object in memory that represents a server. But here's the important part:

No port is open yet. Nobody can connect to your machine on port 3000. Nothing has happened on the network.

Think of it like buying a restaurant building. You now own a restaurant, but the front door is still locked and there's no sign outside. Nobody knows it exists.

Step 3: server.listen(3000) Opens the Door

This is where the real magic happens. When you call server.listen(3000), Node.js talks to your operating system and asks it to do three things:

3a. Create a Socket (socket()): The OS creates a network communication endpoint called a socket. Think of a socket like a telephone—it's the device that lets your program talk over the network.

3b. Assign Port 3000 (bind()): The OS assigns port 3000 to this socket.

What is a port? Your computer has one IP address (like 192.168.1.5), but it can run hundreds of programs that all need network access. Ports are like apartment numbers in a building:

TEXT
Your Computer (Building at 192.168.1.5)
├── Port 80    → Web server (Apache)
├── Port 443   → HTTPS server
├── Port 3000  → Our Node.js app ← This is us!
├── Port 5432  → PostgreSQL database
└── Port 27017 → MongoDB

Each program gets its own port number so the OS knows which program should receive which network message.

3c. Start Listening (listen()): The OS marks this socket as "ready to accept incoming connections." It's like flipping the "OPEN" sign on your restaurant door.

Step 4: Node Waits (Without Freezing)

After server.listen(3000) completes, your JavaScript file has finished executing. But the Node process does NOT exit. Why?

Because libuv (Node's built-in helper for watching the network) is still holding onto that open socket. As long as there's something to watch, Node stays alive—quietly waiting for visitors, using almost zero CPU.

The Four Players Working Together

Let's name the four components that made this happen:

TEXT
┌─────────────────────────────────────────────────────┐
│  1. OPERATING SYSTEM (OS)                           │
│     The boss. Owns the network card, manages ports, │
│     and actually sends/receives data over the wire. │
└──────────────────────▲──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│  2. libuv                                           │
│     Node's tireless watchman. It sits by the        │
│     network door and shouts "Hey, someone's here!"  │
│     whenever data arrives on a socket.              │
└──────────────────────▲──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│  3. V8 Engine                                       │
│     The brain. It reads and executes your           │
│     JavaScript code line by line.                   │
└──────────────────────▲──────────────────────────────┘
                       │
┌──────────────────────▼──────────────────────────────┐
│  4. Your JavaScript Code                            │
│     The instructions you wrote in app.js.           │
└─────────────────────────────────────────────────────┘
  • Your code says: "Create a server that responds with Hello World."
  • V8 reads and executes that code.
  • libuv asks the OS to open port 3000 and then watches for incoming traffic.
  • The OS physically manages the network hardware and routes packets to port 3000.

What is a File Descriptor?

On Linux and macOS, the OS tracks every open resource (files, sockets, pipes) using a simple integer called a File Descriptor (FD). When Node opens port 3000, the OS gives back something like:

TEXT
Node.js Process
├── fd 0  → Standard Input (your keyboard)
├── fd 1  → Standard Output (your terminal screen)
├── fd 2  → Standard Error (error messages)
└── fd 18 → TCP Socket listening on port 3000

You never interact with these numbers directly in your JavaScript code—Node handles them for you. But now you know that behind the scenes, your listening server is just a numbered slot in the OS's resource table.


Part 2: What Happens When Someone Visits Your Server?

Our server is listening on port 3000. Now let's visit it:

BASH
curl http://localhost:3000

Or simply open http://localhost:3000 in your browser. Here is the exact journey of that request:

TEXT
1. YOUR BROWSER
   │  Types the URL and hits Enter
   │
   │  "I need to talk to localhost on port 3000"
   ▼

2. TCP CONNECTION (The Handshake)
   │  Your browser and server do a "3-way handshake":
   │    Browser: "Hey, can we talk?" (SYN)
   │    Server:  "Sure, I'm ready!"  (SYN-ACK)
   │    Browser: "Great, let's go!"  (ACK)
   │
   │  Now there's an open two-way channel between them.
   ▼

3. OPERATING SYSTEM
   │  The OS receives the raw network packets on port 3000.
   │  It creates a new dedicated socket just for THIS conversation.
   │  It notifies libuv: "Hey, data arrived!"
   ▼

4. libuv (Event Loop)
   │  Wakes up and reads the raw bytes from the socket.
   │  Passes those bytes to Node's HTTP parser.
   ▼

5. HTTP PARSER (llhttp)
   │  Reads the raw text:
   │    "GET / HTTP/1.1\r\nHost: localhost:3000\r\n\r\n"
   │
   │  Extracts:
   │    Method: GET
   │    Path: /
   │    Headers: Host=localhost:3000
   │
   │  Creates two JavaScript objects:
   │    req (the incoming request)
   │    res (the outgoing response)
   ▼

6. YOUR CALLBACK FUNCTION
   │  V8 runs your code:
   │    (req, res) => { res.end("Hello World!\n"); }
   │
   │  Your code writes "Hello World!" into the response.
   ▼

7. RESPONSE GOES BACK
   │  Node serializes the response into HTTP format:
   │    "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello World!\n"
   │
   │  libuv sends these bytes back through the socket.
   │  The OS transmits them over the network.
   │  The browser reads them and displays "Hello World!"

TCP Connection vs. HTTP Request — They Are NOT the Same Thing

This is a concept that trips up many developers. Let's make it crystal clear:

TCP Connection = A phone call. When you dial someone's number and they pick up, you have a connection. The line is open. You can talk back and forth. This is TCP—it's the reliable, two-way pipe between two computers.

HTTP Request = A sentence you say during that phone call. Once the call is connected, you say something like: "Can you send me the homepage?" That sentence is the HTTP request.

Here's the key insight: one phone call can carry many sentences. Under HTTP/1.1 (which is the default), the browser keeps the TCP connection open and sends multiple HTTP requests over the same connection:

TEXT
TCP Connection (opened once)
├── HTTP Request 1: GET /index.html
├── HTTP Request 2: GET /styles.css
├── HTTP Request 3: GET /logo.png
└── (Connection stays open for a while, then closes)

So TCP is the pipe. HTTP is the language spoken through the pipe.


Part 3: Our Server Needs Data — Connecting to MongoDB

Right now our server can only say "Hello World!" Every real application needs to store and retrieve data—user profiles, orders, blog posts, etc.

We will use MongoDB (a database that stores data as JSON-like documents) and Mongoose (a library that makes working with MongoDB easier from Node.js).

Create a new file called db.js:

JAVASCRIPT
// db.js
import mongoose from "mongoose";

async function connectDB() {
  const uri = process.env.MONGO_URI || "mongodb://127.0.0.1:27017/my_app_db";

  try {
    const conn = await mongoose.connect(uri, {
      maxPoolSize: 10,
      serverSelectionTimeoutMS: 5000,
    });

    console.log(`[DB] Connected to MongoDB at ${conn.connection.host}`);
  } catch (error) {
    console.error("[DB] Failed to connect:", error.message);
    process.exit(1);
  }
}

export default connectDB;

Let's understand every important part of this code.

MongoDB is NOT Inside Node.js

This is a common misconception. MongoDB is a completely separate program that runs on its own. It might be running on the same computer as your Node.js app, or it could be on a server thousands of miles away:

TEXT
┌──────────────────────────┐         ┌──────────────────────────┐
│   Your Node.js Server    │  TCP    │     MongoDB Server       │
│                          │ ──────► │                          │
│   Listens on port 3000   │         │   Listens on port 27017  │
│   (for HTTP clients)     │ ◄────── │   (for database queries) │
└──────────────────────────┘         └──────────────────────────┘

When mongoose.connect() runs, Node.js opens a completely separate TCP connection to MongoDB on port 27017—the same way your browser opens a TCP connection to your server on port 3000.

What is maxPoolSize: 10?

Opening a new network connection to a database is slow. It involves:

  1. A TCP handshake (3 round trips).
  2. Authentication (username/password verification).
  3. Protocol negotiation.

If our server opened a brand-new database connection for every incoming HTTP request and closed it afterwards, the overhead would be enormous.

Instead, Mongoose creates a Connection Pool. Think of it like this:

Imagine you work in an office and you need to call your supplier frequently. Instead of dialing their number, waiting for them to pick up, verifying your identity, and hanging up every single time—you keep 10 phone lines permanently open to the supplier. Whenever you need something, you pick up an available line, make your request, and put the phone back.

That's what maxPoolSize: 10 does. Mongoose opens 10 persistent connections to MongoDB at startup and reuses them for every query. This makes database operations much faster.

Why process.exit(1) on Failure?

JAVASCRIPT
} catch (error) {
  console.error("[DB] Failed to connect:", error.message);
  process.exit(1);
}

If the database is down, our server cannot do anything useful. Every request that needs data would fail. Instead of running a broken server that returns errors to every user, we immediately shut down with exit code 1.

Why exit code 1 specifically?

  • Exit code 0 means: "Everything went fine. I finished successfully."
  • Exit code 1 means: "Something went wrong. I crashed."

Container orchestrators (like Docker or Kubernetes) watch for non-zero exit codes. When they see one, they know the app failed and can automatically restart it or alert the dev team.


Part 4: The Startup Order Matters — The Readiness Problem

Now let's connect our database in app.js. Here's the wrong way to do it:

JAVASCRIPT
// ❌ WRONG: The Naive Approach
import http from "node:http";
import connectDB from "./db.js";

connectDB();       // Starts connecting (but doesn't wait!)
server.listen(3000); // Opens the door immediately!

Can you spot the problem?

connectDB() returns a Promise. It takes time—maybe 50ms, maybe 2 seconds if the database is far away. But we called server.listen(3000) on the very next line without waiting for the database connection to finish!

Here's what can go wrong:

TEXT
Timeline:
  0ms:  Node starts
  1ms:  connectDB() begins TCP handshake to MongoDB... (still working)
  2ms:  server.listen(3000) opens port 3000 to the public
 10ms:  A user visits http://localhost:3000/users
 11ms:  Your code tries User.find() → 💥 CRASH! Database not connected yet!
200ms:  MongoDB connection finally completes (too late for that user!)

The problem is that our server told the world "I'm open for business!" before it was actually ready.

Liveness vs. Readiness

These are two important concepts in production systems:

  • Liveness: "Is the process running?" — Yes, Node.js started and is executing code.
  • Readiness: "Is the application actually prepared to handle real traffic?" — Not yet! The database isn't connected.

Rule: Never open your doors to the public until you are truly ready.

The Correct Startup Pattern

JAVASCRIPT
// ✅ CORRECT: Wait for dependencies before accepting traffic
import http from "node:http";
import connectDB from "./db.js";

const PORT = process.env.PORT || 3000;

async function startServer() {
  try {
    // Step 1: Wait for database connection FIRST
    await connectDB();

    // Step 2: Create the server
    const server = http.createServer((req, res) => {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "ready" }));
    });

    // Step 3: ONLY open the port after database is confirmed ready
    server.listen(PORT, () => {
      console.log(`[Server] Ready on port ${PORT}`);
    });
  } catch (error) {
    console.error("[Server] Failed to start:", error);
    process.exit(1);
  }
}

startServer();

Now the startup sequence is guaranteed to be safe:

TEXT
1. Process starts
      ↓
2. Connect to database (await — waits until done)
      ↓
3. Database confirmed connected ✅
      ↓
4. Open port 3000 to the public
      ↓
5. Ready to safely handle requests

Part 5: The Problem — We Have No Menu

Our server is online, connected to the database, and safely listening. But try these different requests:

BASH
curl http://localhost:3000/users
curl http://localhost:3000/products
curl -X POST http://localhost:3000/users

They ALL return the exact same thing: {"status":"ready"}. Our server treats every request identically—it has no idea what the visitor wants.

Think of a restaurant again:

  • ✅ The kitchen is built.
  • ✅ The fridge (database) is stocked.
  • ✅ The front door is open.
  • ❌ But there's no menu!

Customers walk in and say "I'd like the pasta" but the kitchen doesn't understand what "pasta" means. Every customer gets the same default plate.

This is the problem that Routing solves.

What is a Route?

A route is a combination of two things:

TEXT
Route = HTTP Method + URL Path

Here are some examples:

TEXT
GET    /users       →  "Show me all users"
POST   /users       →  "Create a new user"
GET    /users/123   →  "Show me user number 123"
DELETE /users/123   →  "Delete user number 123"

The HTTP method (GET, POST, PUT, DELETE) tells the server what operation the client wants. The path (/users, /users/123) tells the server what resource the client is asking about.

Together, they form a unique instruction that the server can understand and respond to.


Part 6: Why We Need Express

Could we build routing with plain Node.js? Sure. Let's try:

JAVASCRIPT
// Routing with raw Node.js (ugly but works)
const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url === "/users") {
    res.end("Here are the users");
  } else if (req.method === "POST" && req.url === "/users") {
    // To read the POST body, we need to:
    let body = "";
    req.on("data", (chunk) => { body += chunk; });
    req.on("end", () => {
      const parsed = JSON.parse(body);
      res.end("Created user: " + parsed.name);
    });
  } else if (req.method === "GET" && req.url.startsWith("/users/")) {
    const id = req.url.split("/")[2]; // Manual URL parsing 😬
    res.end("User ID: " + id);
  } else {
    res.writeHead(404);
    res.end("Not Found");
  }
});

This works for 3 routes. Now imagine 50 routes. With authentication. With error handling. With input validation. You would be writing thousands of lines of repetitive boilerplate code.

Express is a lightweight framework that handles all this tedious work for you. It gives you:

Problem Express Solution
Manual URL pattern matching app.get("/users/:id", handler) — automatic parameter extraction
Manually reading request body streams express.json() — automatic JSON parsing
Copy-pasting auth checks into every route Middleware — write once, apply everywhere
No centralized error handling Error middleware catches all errors in one place

Let's switch to Express:

BASH
npm install express cors
JAVASCRIPT
// app.js — now with Express
import express from "express";
import connectDB from "./db.js";

const app = express();
const PORT = process.env.PORT || 3000;

app.get("/users", (req, res) => {
  res.json({ message: "List of users" });
});

async function startServer() {
  try {
    await connectDB();
    app.listen(PORT, () => {
      console.log(`[Express] Running on port ${PORT}`);
    });
  } catch (error) {
    console.error("[Express] Startup failed:", error);
    process.exit(1);
  }
}

startServer();

Critical Concept: Express Does NOT Replace Node's HTTP Server

Many developers think Express is a separate server. It is not.

When you call:

JAVASCRIPT
app.listen(3000);

Express is secretly doing this under the hood:

JAVASCRIPT
import http from "node:http";

const server = http.createServer(app);  // Express is just a callback function!
server.listen(3000);

Express is a function that Node's HTTP server calls whenever a request arrives. It adds routing, middleware, and helper methods on top of Node's built-in server. Here is the relationship:

TEXT
Your Express Code (app.get, app.post, middleware)
       ↓ sits on top of
Node.js HTTP Server (parses raw HTTP bytes into req/res objects)
       ↓ sits on top of
libuv (watches network sockets asynchronously)
       ↓ sits on top of
Operating System (manages TCP connections and hardware)

They are layers, like floors in a building. Express is the top floor; the OS is the foundation.


Part 7: Middleware — The Security Checkpoints

When a request arrives at your Express app, it doesn't jump straight into your route handler. It passes through a series of middleware functions first—like going through security checkpoints at an airport.

TEXT
Incoming Request
       │
       ▼
┌──────────────────┐
│ Checkpoint 1     │  "Do you have a JSON body? Let me parse it for you."
│ (express.json()) │
└──────┬───────────┘
       │ next()  ← "All clear, move to next checkpoint"
       ▼
┌──────────────────┐
│ Checkpoint 2     │  "Do you have form data? Let me parse that too."
│ (urlencoded)     │
└──────┬───────────┘
       │ next()
       ▼
┌──────────────────┐
│ Checkpoint 3     │  "Is this request from a different website? Let me
│ (cors)           │   add the right permission headers."
└──────┬───────────┘
       │ next()
       ▼
┌──────────────────┐
│ Your Route       │  "GET /users? Here's your data!"
│ (handler)        │
└──────┬───────────┘
       │ res.json()  ← "Here's the response, send it back!"
       ▼
Response Sent

A middleware function receives three arguments:

  • req — the incoming request
  • res — the outgoing response
  • next — a function that means: "I'm done with my job. Pass the request to the next checkpoint."

Let's add the three essential middlewares:

JAVASCRIPT
import express from "express";
import cors from "cors";
import connectDB from "./db.js";

const app = express();

// Middleware 1: Parse JSON request bodies
app.use(express.json());

// Middleware 2: Parse URL-encoded form data
app.use(express.urlencoded({ extended: true }));

// Middleware 3: Handle cross-origin requests
app.use(cors());

What Does express.json() Actually Do?

When a client sends data to your server (like when a user registers), the data arrives as raw bytes streaming over the network. Your code can't use raw bytes—it needs a JavaScript object.

express.json() does this work for you:

  1. Checks the request's Content-Type header. If it's not application/json, it skips and calls next().
  2. Listens to the incoming byte stream and collects all the chunks.
  3. Enforces a size limit (default 100KB) so nobody can crash your server by sending a 10GB payload.
  4. Runs JSON.parse() to convert the text into a JavaScript object.
  5. Attaches the result to req.body.
  6. Calls next() so the next middleware or route can use req.body.

What Does express.urlencoded() Do?

The same thing as express.json(), but for HTML form submissions. When you submit a form on a website, the browser sends data like name=Alice&email=alice@mail.com. This middleware parses that format into { name: "Alice", email: "alice@mail.com" }.

What Does cors() Do?

If your frontend runs on http://localhost:5173 and your API runs on http://localhost:3000, web browsers will block the frontend from talking to the API. This is a security feature called Same-Origin Policy.

cors() tells the browser: "It's okay, I trust this frontend. Let it access my API." It does this by adding permission headers like Access-Control-Allow-Origin to your responses.

Important: CORS is enforced by browsers only. Tools like curl, Postman, or another backend server will happily talk to your API without CORS. It's purely a browser safety mechanism.


Part 8: Modular Routes — Organizing by Feature

If we put every route in app.js, that file would grow to thousands of lines. Instead, we group related routes into their own file.

Create features/users/user.routes.js:

JAVASCRIPT
// features/users/user.routes.js
import { Router } from "express";

const router = Router();

router.get("/", (req, res) => {
  res.json({ users: [] });
});

router.get("/:id", (req, res) => {
  res.json({ id: req.params.id, name: "Sample User" });
});

export default router;

Now mount it in app.js:

JAVASCRIPT
import userRoutes from "./features/users/user.routes.js";

// Mount all user routes under the /api/users prefix
app.use("/api/users", userRoutes);

How Does Route Mounting Work?

When you write app.use("/api/users", userRoutes), you are telling Express:

"Any request that starts with /api/users — hand it to userRoutes to handle."

Let's trace what happens when a client sends GET /api/users/123:

  1. Express looks at the URL: /api/users/123
  2. It checks its registered route mounts. Does it start with /api/users? Yes!
  3. Express strips the prefix and hands the remaining path (/123) to userRoutes.
  4. userRoutes checks its own route list:
    • Does /123 match /? No.
    • Does /123 match /:id? Yes! The :id part is a parameter placeholder that matches any value.
  5. Express sets req.params.id = "123" and runs the handler function.

What About Multiple Feature Routes?

JAVASCRIPT
app.use("/api/users", userRoutes);
app.use("/api/products", productRoutes);
app.use("/api/orders", orderRoutes);

When a request for GET /api/products/456 comes in:

TEXT
Check /api/users     → URL starts with /api/products, NOT /api/users → SKIP
Check /api/products  → MATCH! → Enter productRoutes → find /:id → handle it
Check /api/orders    → Never reached (already handled above)

Express checks mounts in order, top to bottom. The first match wins. If nothing matches, Express returns a 404.


Part 9: Controllers and Services — The Dream Team

We have routes working! But right now, our route handler is doing everything:

JAVASCRIPT
// ❌ Everything jammed into one function
router.get("/:id", async (req, res) => {
  // Reading HTTP params
  // Validating input
  // Querying the database
  // Formatting the response
  // Handling errors
  // All in one place!
});

This creates two big problems:

  1. You can't reuse the logic. What if a background job also needs to fetch a user? You'd have to duplicate the database query code.
  2. It's hard to test. To test the database logic, you'd have to fake an entire HTTP request.

The solution is to split responsibilities into distinct roles. Think of a restaurant:

TEXT
┌──────────────────────────────────────────────────────┐
│                    THE RESTAURANT                     │
│                                                       │
│  HOST (Routes)                                        │
│  "Welcome! Table for GET /api/users/123?"             │
│  → Directs you to the right waiter                    │
│                                                       │
│  WAITER (Controller)                                  │
│  "What would you like?" (reads req.params)            │
│  → Takes the order to the kitchen                     │
│  → Brings back the plate (sends res.json())           │
│                                                       │
│  CHEF (Service)                                       │
│  Cooks the food (business logic)                      │
│  → Doesn't know or care about the customer            │
│  → Just receives an order and produces a result       │
│                                                       │
│  PANTRY (Model)                                       │
│  The ingredients catalog (database schema)            │
│  → Defines what exists and how it's stored            │
│                                                       │
│  WAREHOUSE (MongoDB)                                  │
│  Where all ingredients are physically stored          │
└──────────────────────────────────────────────────────┘

Let's build each piece inside features/users/:

The Model — features/users/user.model.js

Defines the shape of a "User" in MongoDB:

JAVASCRIPT
// features/users/user.model.js
import mongoose from "mongoose";

const userSchema = new mongoose.Schema(
  {
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, unique: true, lowercase: true },
    role: { type: String, default: "member" },
  },
  { timestamps: true }
);

const User = mongoose.models.User || mongoose.model("User", userSchema);

export default User;

The Service — features/users/user.service.js

Contains the business logic. The most important rule about the service:

The service has ZERO knowledge of HTTP. It doesn't know what req or res are. It takes plain values (like a string ID) and returns plain data (like a user object).

JAVASCRIPT
// features/users/user.service.js
import mongoose from "mongoose";
import User from "./user.model.js";

export async function getUserById(id) {
  if (!mongoose.Types.ObjectId.isValid(id)) {
    throw new Error("Invalid User ID format");
  }

  const user = await User.findById(id).lean();

  if (!user) {
    throw new Error("User not found");
  }

  return user;
}

export async function createUser(data) {
  const user = await User.create(data);
  return user.toObject();
}

[!NOTE] Notice how we directly throw new Error(...): Here, our service directly throws a plain JavaScript error without worrying about HTTP details. But wait—how does Express know whether "User not found" should be a 404, or "Invalid User ID format" should be a 400? Right now, Express has no idea! Both errors arrive as generic JavaScript errors without status codes, which defaults to 500.

Manually attaching error.statusCode = 404 inside services quickly becomes messy, repetitive, and leaks HTTP details into business logic. In Chapter 7: Backend Utilities & Error Architecture, we will see what the production-grade approach is for handling errors cleanly using our own ApiError class with static factories (like ApiError.notFound() and ApiError.badRequest())! For now, let's keep our service simple and directly throw errors so we can observe how errors travel through Express.

Why doesn't the service know about HTTP? Because this same function can be used by:

  • An Express controller (HTTP request)
  • A background cron job
  • A CLI script
  • A WebSocket event handler
  • A unit test

If it depended on req and res, none of those other uses would work.

The Controller — features/users/user.controller.js

The controller is the translator between the HTTP world and the business logic world:

  1. It reads data from the HTTP request (req.params, req.body).
  2. It calls the service with plain values.
  3. It takes the service's result and sends an HTTP response (res.json()).
  4. It catches errors and passes them to the error handler.
JAVASCRIPT
// features/users/user.controller.js
import * as userService from "./user.service.js";

export async function getUser(req, res, next) {
  try {
    const { id } = req.params;
    const user = await userService.getUserById(id);

    return res.status(200).json({
      success: true,
      data: user,
    });
  } catch (error) {
    next(error);  // Pass the error to our global error handler
  }
}

export async function createUser(req, res, next) {
  try {
    const newUser = await userService.createUser(req.body);

    return res.status(201).json({
      success: true,
      data: newUser,
    });
  } catch (error) {
    next(error);
  }
}

The Routes — features/users/user.routes.js

Now our routes file becomes beautifully simple:

JAVASCRIPT
// features/users/user.routes.js
import { Router } from "express";
import { getUser, createUser } from "./user.controller.js";

const router = Router();

router.get("/:id", getUser);
router.post("/", createUser);

export default router;

Summary of Responsibilities

File Role What It Does What It Should NEVER Do
user.routes.js The Host Maps URLs to controller functions Contain business logic or DB queries
user.controller.js The Waiter Reads req, calls service, sends res Contain business rules or raw DB queries
user.service.js The Chef Business logic, validation, orchestration Touch req, res, or HTTP status codes
user.model.js The Pantry Catalog Defines database schema and queries Format HTTP responses

Part 10: Understanding next() and Error Handling

The Three Things You Can Do in a Middleware

In any middleware or route handler, you have three choices:

1. Call next() — "I'm done, pass to the next checkpoint"

JAVASCRIPT
app.use((req, res, next) => {
  console.log("Request received at:", new Date());
  next();  // Move to the next middleware or route
});

2. Send a response — "Here's the answer, we're done"

JAVASCRIPT
app.get("/health", (req, res) => {
  res.json({ status: "ok" });  // Response sent. Request cycle ends.
});

3. Call next(error) — "Something went wrong!"

JAVASCRIPT
app.use((req, res, next) => {
  if (!req.headers.authorization) {
    const error = new Error("No auth token provided");
    error.statusCode = 401;
    next(error);  // SKIP all remaining routes, jump to error handler
  } else {
    next();
  }
});

Centralized Error Handling

Instead of writing try/catch and error responses in every single route, we create one error handler at the bottom of app.js:

JAVASCRIPT
// This goes AFTER all routes in app.js
app.use((err, req, res, next) => {
  const statusCode = err.statusCode || 500;

  console.error(`[Error] ${req.method} ${req.url} → ${statusCode}: ${err.message}`);

  res.status(statusCode).json({
    success: false,
    error: {
      message: err.message || "Internal Server Error",
    },
  });
});

Now when any controller calls next(error), Express skips everything and jumps straight to this error handler.

Why Does the Error Handler Have 4 Parameters?

Look at the function signature: (err, req, res, next) — that's 4 parameters, while normal middleware has only 3: (req, res, next).

How does Express know the difference?

Express checks function.length. In JavaScript, every function has a .length property that tells how many parameters it was declared with:

JAVASCRIPT
function normalMiddleware(req, res, next) {}
console.log(normalMiddleware.length);  // 3

function errorMiddleware(err, req, res, next) {}
console.log(errorMiddleware.length);  // 4

Express uses this rule: if a function has exactly 4 parameters, it's an error handler. When next(error) is called, Express skips all 3-parameter functions and only runs 4-parameter functions.


Part 11: The Full Journey — Tracing One Complete Request

Let's put it all together. A user makes this request:

HTTP
GET /api/users/65bb0a38e8202d6b1d1e44a2

Here is the complete journey, step by step:

TEXT
 1. BROWSER
    │  User clicks a link or types the URL
    ▼

 2. TCP CONNECTION
    │  Browser connects to localhost:3000 (3-way handshake)
    ▼

 3. OPERATING SYSTEM
    │  Receives raw packets on port 3000
    │  Notifies libuv: "Data arrived!"
    ▼

 4. libuv
    │  Reads raw bytes from the network socket
    │  Passes them to Node's HTTP parser
    ▼

 5. HTTP PARSER (llhttp)
    │  Parses: "GET /api/users/65bb0a... HTTP/1.1"
    │  Creates req and res JavaScript objects
    │  Calls the Express app function
    ▼

 6. express.json()
    │  "Is there a JSON body?" → No, it's a GET request → next()
    ▼

 7. express.urlencoded()
    │  "Is there form data?" → No → next()
    ▼

 8. cors()
    │  Sets Access-Control-Allow-Origin header → next()
    ▼

 9. ROUTE MATCHING
    │  Checks: does /api/users/65bb0a... match /api/users? → YES!
    │  Strips prefix, passes /65bb0a... to userRoutes
    ▼

10. userRoutes
    │  Checks: does /65bb0a... match /:id? → YES!
    │  Sets req.params.id = "65bb0a38e8202d6b1d1e44a2"
    │  Calls getUser controller
    ▼

11. user.controller.js
    │  Extracts id from req.params
    │  Calls userService.getUserById(id)
    ▼

12. user.service.js
    │  Validates that ID is a valid format
    │  Calls User.findById(id)
    ▼

13. MONGOOSE → MongoDB
    │  Borrows an idle connection from the pool
    │  Sends database query to MongoDB over TCP (port 27017)
    │
    │  ════════════════════════════════════════════════
    │  THE NON-BLOCKING MAGIC:
    │  While MongoDB searches for the user on disk,
    │  Node.js does NOT sit frozen waiting!
    │  The event loop is FREE to handle other requests.
    │  Other users can simultaneously get responses.
    │  ════════════════════════════════════════════════
    │
    │  MongoDB finds the user and sends it back
    │  Mongoose converts the raw data into a JS object
    ▼

14. user.service.js (resumes)
    │  Returns the user object to the controller
    ▼

15. user.controller.js (resumes)
    │  Calls res.status(200).json({ success: true, data: user })
    ▼

16. EXPRESS → NODE HTTP SERVER
    │  Converts the JS object to JSON text
    │  Adds HTTP headers (Content-Type: application/json)
    ▼

17. libuv → OPERATING SYSTEM
    │  Sends the response bytes through the TCP socket
    ▼

18. BROWSER
    │  Receives JSON and displays the user data!

The Critical Takeaway: Non-Blocking I/O

Notice step 13. When your code runs await User.findById(id), Node.js does not sit frozen in a loop waiting for MongoDB to respond.

Here is what actually happens:

  1. The query bytes are sent to MongoDB over the network.
  2. The JavaScript function pauses (the await suspends it).
  3. The V8 call stack empties.
  4. The event loop is now free to do other work—handling other HTTP requests, running other callbacks.
  5. When MongoDB's answer arrives over the network, libuv detects it and wakes up the paused function.
  6. The controller resumes and sends the response.

This is why a single-threaded Node.js server can handle thousands of concurrent users. It never wastes time waiting—it's always doing useful work.


Part 12: The Complete Architecture Diagram

Here is the big picture of everything we built:

TEXT
                             CLIENT
                     (Browser, Mobile App, curl)
                                │
                                ▼
                        OPERATING SYSTEM
                     (TCP Socket on Port 3000)
                                │
                                ▼
                              libuv
                     (Event Loop / I/O Watcher)
                                │
                                ▼
                        Node.js HTTP Server
                       (Parses HTTP → req/res)
                                │
                                ▼
                        Express Application
                                │
              ┌─────────────────┼─────────────────┐
              ▼                 ▼                 ▼
        express.json()        cors()       Other Middleware
        (Parse body)     (Browser perms)    (Auth, Logging)
              │                 │                 │
              └─────────────────┼─────────────────┘
                                │ next()
                                ▼
                           Root Router
                                │
                                ▼ Matches /api/users
                         User Feature Router
                   (features/users/user.routes.js)
                                │
                                ▼ Matches GET /:id
                         User Controller
                 (features/users/user.controller.js)
                                │
                                ▼ Calls service with plain data
                         User Service
                   (features/users/user.service.js)
                                │
                                ▼ Database query
                         User Model
                    (features/users/user.model.js)
                                │
                                ▼ TCP to port 27017
                             MongoDB
                                │
                                ▼ Data returned
                         User Service
                                │
                                ▼ Plain JavaScript object
                         User Controller
                                │
                                ▼ res.json(data)
                        Node.js HTTP Server
                                │
                                ▼ Response bytes
                              libuv
                                │
                                ▼
                        OPERATING SYSTEM
                                │
                                ▼ TCP packets
                             CLIENT

Quick Reference: Who Does What?

Layer What It Is What It Does What It Should NEVER Do
OS Your computer's kernel Manages network hardware, ports, and TCP connections Run your app code
libuv C library inside Node Watches for network/file events without freezing Execute JavaScript
V8 JavaScript engine Reads and runs your JS code Make direct hardware calls
Node HTTP Built-in module Turns raw bytes into req/res objects Handle business logic
Middleware app.use(...) Parse bodies, set headers, check auth Query the database
Routes user.routes.js Map URL patterns to controller functions Contain business logic
Controller user.controller.js Read HTTP request, call service, send HTTP response Know database details
Service user.service.js Business rules and data orchestration Touch req or res
Model user.model.js Define database schema and run queries Format HTTP responses

Summary & What Comes Next

We started with a blank file and built a complete mental model of how a Node.js backend works:

  1. node app.js creates a process with V8 and libuv.
  2. server.listen(3000) asks the OS to open a socket on port 3000.
  3. We connect to MongoDB first before opening port 3000, because a server should only accept traffic when it's truly ready.
  4. Express sits on top of Node's HTTP server—it doesn't replace it.
  5. Middleware forms an ordered checkpoint pipeline that every request passes through.
  6. Feature-wise architecture (features/users/) keeps models, services, controllers, and routes together.
  7. The event loop lets Node handle thousands of users on a single thread by never blocking on I/O.

We now have a complete mental model of how a request enters a Node.js application. In the next chapter, Chapter 7: Backend Utilities & Error Architecture, we will eliminate repetitive try/catch boilerplate with asyncHandler, standardize responses with ApiResponse, and build an enterprise-grade centralized error pipeline.

Finished this lesson?

Mark this chapter complete to update your learning streak and unlock the next lesson.