In this chapter
We'll follow one real request through an unfamiliar codebase — route, middleware, controller, service and database, and back — to understand exactly where a new feature belongs, explained as following a river from its source to the sea.
The Problem in Real Life
Anna picks one request to follow end to end: GET /api/events/311/sales — the one the dashboard sends when an organiser opens an event. "If I understand this one completely," she says, "my counter is just a smaller version of it."
John draws four boxes on the whiteboard and connects them with arrows: route → controller → service → database. "Most backends you'll ever see are some version of these four boxes. Find them here."
Follow one request all the way, and the whole codebase starts to make sense.
John
Knowing Where the Code Is vs. Knowing What It Does, Step by Step
Code split across layers
One request touches four or five files. You need to see them as one journey.
Rules hidden along the way
Authorization, validation and filters live in different layers — miss one and you create a bug.
Where does new code go?
A new feature should follow the same path and patterns as existing ones.
Tracing a Request: Route, Middleware, Controller, Service, Database
The river analogy: to understand a river, you follow it from its source to the sea: where streams join it, where a dam controls it, where water is taken out. After that one journey, you understand the whole valley. Tracing one request through a codebase is following the river.
- The common layers — most backends look like this: a route says "this URL and method go to this function". Middleware runs checks on the way (auth, logging — Act 23). A controller (or handler) reads the request — URL parameters, query, body — and calls the business logic. A service contains the business logic — the rules. A data access layer (repository, model, or direct queries) talks to the database. Then the result flows back up as a response.
- How to trace — two ways: by reading: start at the route and use go-to-definition at each call. By running: set a breakpoint in the route handler (Act 17), send the request, and step into each call, watching the variables. Reading shows the structure; running shows the real values. Use both.
- What to note at each step: what goes in, what comes out, which checks happen (authentication, authorization, validation), and any side effects (database writes, queue messages, caching).
| Layer | File | In | Out / effect |
|---|---|---|---|
| Route | routes/sales.js | GET /events/311/sales | Calls middleware + controller |
| Middleware | middleware/auth.js | Authorization header | req.user.organiserId (or 401) |
| Controller | controllers/salesController.js | eventId, organiserId | JSON response |
| Service | services/salesService.js | eventId, organiserId | Sales per ticket type |
| Data access | db/index.js | SQL + parameters | Rows from PostgreSQL |
GET /api/events/311/sales, traced
Dashboard.jsx
fetch(/api/events/311/sales)
server.js → /api routes
the entry point mounts the routes
routes/sales.js
GET /events/:eventId/sales
middleware/auth.js
JWT → req.user.organiserId
salesController.getSales
reads eventId, calls the service
salesService.getSalesForEvent
SQL filtered by event_id AND organiser_id
db.query → PostgreSQL
parameterised query
// routes/sales.jsrouter.get("/events/:eventId/sales", requireOrganiser, salesController.getSales);// controllers/salesController.jsexports.getSales = async (req, res, next) => {try {const sales = await salesService.getSalesForEvent(req.params.eventId, req.user.organiserId);res.json(sales);} catch (err) { next(err); }};// services/salesService.jsexports.getSalesForEvent = (eventId, organiserId) =>db.query(`SELECT o.ticket_type, SUM(o.quantity) AS sold, SUM(o.total) AS revenueFROM orders o JOIN events e ON e.id = o.event_idWHERE o.event_id = $1 AND e.organiser_id = $2 AND o.status = 'paid'GROUP BY o.ticket_type`,[eventId, organiserId]).then((r) => r.rows);
The organiser filter (e.organiser_id = $2) is the authorization rule. Any new sales query must keep it.
The trace, step by step: (1) route routes/sales.js: router.get("/events/:eventId/sales", requireOrganiser, salesController.getSales). (2) middleware requireOrganiser: checks the JWT and sets req.user.organiserId (chapter two). (3) controller getSales: reads req.params.eventId, calls salesService.getSalesForEvent(eventId, req.user.organiserId), returns the result as JSON. (4) service getSalesForEvent: runs one SQL query that sums sold tickets and revenue per ticket type — filtered by both event_id and organiser_id, so an organiser can never see another organiser's sales (authorization lives here, in the query). (5) database via db.query with parameters (Act 22). (6) Back up: an array of ticket types with counts, returned as 200 OK.
Where the counter belongs: Anna now knows exactly what to build: a new route GET /events/:eventId/sales-count, using the same requireOrganiser middleware, a small controller, and a new service function with a simpler query — total tickets sold — that keeps the same organiser filter. Copying the existing pattern means the new code fits in, and the authorization rule can't be forgotten.
A bonus find: while stepping through with the debugger, she notices the query runs on every dashboard load and takes 300 ms on Sale-Day-sized data. A counter that refreshes every 10 seconds for thousands of organisers will need something lighter. She adds it to her notes: "count query — check the index on orders(event_id, status)" (Act 13).
Key Takeaway
Trace one request end to end, like following a river: route → middleware → controller → service → data access → database, and back as a response. Trace by reading (go to definition) and by running (breakpoint and step into). At each step note inputs, outputs, checks and side effects — especially where authorization lives. Then build new features by following the same path and patterns, so rules like the organiser filter can't be forgotten.
Why This Matters
Tracing a request is how experienced developers understand a system quickly, and it's the safest way to add features: you see the existing pattern and every rule along the path. Knowing the route–controller–service–database shape lets you find your way in most backend codebases, in any language.
Anna knows exactly what to change. But reading isn't enough — before she changes anything, she wants the whole project running on her own laptop, so she can watch her counter tick.
